Primitive Data Types - int, float, bool, and NoneType at Engineering Depth
Reading time: ~25 minutes | Level: Foundation → Engineering
Before we start, try predicting the output of this snippet:
print(True + True + True)
print(isinstance(True, int))
print(0.1 + 0.2 == 0.3)
print(0.1 + 0.2)
x = None
y = None
print(x is y)
If any of those results surprised you - 3, True, False, 0.30000000000000004, True - then you are working with a surface-level mental model of Python's type system. Those results are not quirks or bugs. They are the direct, logical consequences of how Python's type system is engineered. This lesson explains every one of them from first principles.
What You Will Learn
- Why Python has no "primitive" in the C sense - everything is an object, and what that actually means for memory and performance
int: arbitrary precision, CPython's small integer cache (-5to256),sys.getsizeofshowing size growth with magnitude, and integer literal syntax (hex, octal, binary, underscore separators)float: IEEE-754 double-precision layout (1 sign + 11 exponent + 52 mantissa bits), why0.1 + 0.2 != 0.3,math.isclose()for safe comparison,decimal.Decimalfor financial arithmetic,float('inf'),float('nan'), and the NaN comparison gotchabool: why it is a subclass ofint, whatTrue == 1andFalse == 0means for arithmetic, and when this design causes bugsNoneType: the singleton guarantee, why you must useis Noneinstead of== None, None as a sentinel value, and the distinction between None,0, andFalse- Type coercion and arithmetic promotion rules
- Immutability: why scalars are immutable, what this enables (hashing, dict keys, thread safety)
sys.getsizeof- Python object overhead vs a C struct
Prerequisites
- Completion of earlier lessons in this module
- Familiarity with variables and assignment
- Basic knowledge of the execution model (stack frames, heap objects)
Everything Is an Object - What That Actually Means
In C, int x = 42; stores the 4-byte two's-complement representation of 42 directly on the stack. The variable is the integer. There is no overhead beyond those 4 bytes.
In Python, x = 42 creates an int object on the heap and stores a reference to it in the frame's local variable array. That heap object is not just 42. It is a full CPython PyObject structure:
import sys
print(sys.getsizeof(42)) # 28
print(sys.getsizeof(0)) # 24
print(sys.getsizeof(True)) # 28
print(sys.getsizeof(None)) # 16
This overhead is the cost of Python's dynamism: every object carries its type and reference count at runtime. The benefit is a uniform object model - every value supports the same introspection (type(), isinstance(), id(), help()) and memory management (garbage collection via reference counting + cyclic GC).
The term "primitive" in Python documentation typically means "built-in scalar type." It is not the same as a C primitive. Python's int is a full object. There is no way to have a bare, unboxed integer value in standard CPython - every integer is heap-allocated and reference-counted.
int - Arbitrary Precision Integers
No Overflow - Ever
Python's int is an arbitrary-precision (bignum) integer. It allocates as many digits (internally base 2^30) as needed to represent any value. There is no fixed width and therefore no overflow:
x = 2 ** 1000
print(x) # A 302-digit number - no overflow
print(type(x)) # <class 'int'>
factorial = 1
for i in range(1, 101):
factorial *= i
print(factorial) # 100! - a 158-digit number, exact
Compare this to C's int (32-bit, max ~2.1 billion) or long long (64-bit, max ~9.2 × 10¹⁸). Python silently handles numbers far larger.
How Size Grows with Magnitude
Because int allocates variable-width storage, larger numbers consume more memory:
import sys
for n in [0, 1, 255, 256, 2**30, 2**60, 2**90, 2**1000]:
print(f"sys.getsizeof({n!r:<10}) = {sys.getsizeof(n)} bytes")
sys.getsizeof(0) = 24 bytes
sys.getsizeof(1) = 28 bytes
sys.getsizeof(255) = 28 bytes
sys.getsizeof(256) = 28 bytes
sys.getsizeof(1073741824) = 28 bytes
sys.getsizeof(...) = 32 bytes
sys.getsizeof(...) = 36 bytes
sys.getsizeof(2**1000) = 160 bytes
Each additional 30-bit digit adds 4 bytes. Small integers fit in one digit slot (28 bytes); arbitrarily large integers grow linearly.
The CPython Small Integer Cache
CPython pre-allocates int objects for the range [-5, 256] inclusive at interpreter startup. These objects are reused for all references to integers in that range - no new allocation occurs.
a = 100
b = 100
print(a is b) # True - same object from the cache
print(id(a) == id(b)) # True
a = 1000
b = 1000
print(a is b) # False - distinct objects allocated on the heap
Never use is to compare integers for equality. is tests object identity (same memory address), not value equality. The small-int cache makes a is b True for small integers as an implementation detail of CPython that is not guaranteed by the Python language specification. PyPy and other implementations may behave differently. Always use == to compare values.
Why does the cache exist? Small integers (0, 1, -1, loop indices up to 256) are used constantly. Caching them eliminates millions of heap allocations per second in typical programs, significantly reducing garbage collection pressure.
Integer Literal Syntax
Python supports multiple literal notations, all producing int objects:
decimal = 1_000_000 # Underscore separator for readability (PEP 515)
hexadecimal = 0xFF # 255 - prefix 0x, digits 0-9 and a-f
octal = 0o77 # 63 - prefix 0o, digits 0-7
binary = 0b1010_1100 # 172 - prefix 0b, digits 0 and 1
print(decimal) # 1000000
print(hexadecimal) # 255
print(hex(hexadecimal)) # '0xff'
print(oct(octal)) # '0o77'
print(bin(binary)) # '0b10101100'
Underscore separators (1_000_000, 0xFF_FF, 0b1010_1100) are purely cosmetic - the interpreter ignores them. They dramatically improve readability for large constants and memory addresses.
float - IEEE-754 Double Precision
The 64-Bit Layout
Python's float is a C double - a 64-bit IEEE-754 double-precision floating-point number. Understanding its layout explains every "surprising" float behavior:
- Sign (1 bit):
0for positive,1for negative - Exponent (11 bits, biased by 1023): represents the power of 2
- Mantissa (52 bits): the fractional part after the implicit leading
1
This layout gives approximately 15–17 significant decimal digits of precision and a range from about 5 × 10⁻³²⁴ to 1.8 × 10³⁰⁸.
Why 0.1 + 0.2 != 0.3
0.1 cannot be represented exactly in binary floating point. The closest 64-bit double is:
0.1 (exact) = 0.1000000000000000055511151231257827021181583404541015625
0.2 (exact) = 0.2000000000000000111022302462515654042363166809082031250
0.3 (exact) = 0.2999999999999999888977697537484345957636833190917968750
When you add the floating-point representations of 0.1 and 0.2, you get a number slightly larger than the floating-point representation of 0.3:
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
# Inspect the exact value:
from decimal import Decimal
print(Decimal(0.1))
# Decimal('0.1000000000000000055511151231257827021181583404541015625')
This is not a Python bug. It is the mathematically correct result of IEEE-754 arithmetic. Any language using 64-bit doubles (JavaScript, Java, C#, Go) produces the same result.
Safe Float Comparison with math.isclose()
import math
a = 0.1 + 0.2
b = 0.3
# Wrong:
print(a == b) # False
# Correct:
print(math.isclose(a, b)) # True
# Default: rel_tol=1e-9, abs_tol=0.0
# For near-zero comparisons, set abs_tol:
print(math.isclose(1e-10, 0.0, abs_tol=1e-9)) # True
math.isclose(a, b, rel_tol=1e-9, abs_tol=0.0) returns True if |a - b| ≤ max(rel_tol × max(|a|, |b|), abs_tol). Always use relative tolerance for general comparisons and absolute tolerance when values may be near zero.
decimal.Decimal for Financial Arithmetic
from decimal import Decimal, getcontext
# Set precision (default is 28 significant digits)
getcontext().prec = 28
price = Decimal("19.99")
tax_rate = Decimal("0.08")
tax = price * tax_rate
total = price + tax
print(tax) # 1.5992
print(total) # 21.5892
# Contrast with float:
price_f = 19.99
tax_f = price_f * 0.08
print(tax_f) # 1.5992000000000002 - rounding error
# With Decimal:
price_d = Decimal("19.99")
tax_d = price_d * Decimal("0.08")
print(tax_d) # 1.5992 - exact
Never use float for financial calculations. Accumulated rounding errors in float arithmetic cause incorrect results for currency, interest calculations, and tax computations. Use decimal.Decimal with string construction (Decimal("0.10"), not Decimal(0.10)) - constructing from a float preserves the float's imprecision.
Special Float Values
inf = float('inf')
neg_inf = float('-inf')
nan = float('nan')
print(inf + 1) # inf
print(inf - inf) # nan
print(1 / inf) # 0.0
print(-1 / 0.0) # ZeroDivisionError - cannot divide by integer 0
print(-1.0 / 0.0) # ZeroDivisionError - Python raises instead of -inf
print(inf > 1_000_000) # True
The NaN comparison gotcha - NaN is the only value in Python that is not equal to itself:
nan = float('nan')
print(nan == nan) # False ← NaN is never equal to anything, including itself
print(nan != nan) # True
print(nan < 0) # False
print(nan > 0) # False
# Correct way to check for NaN:
import math
print(math.isnan(nan)) # True
This behavior is mandated by IEEE-754: NaN represents an undefined or unrepresentable result (like 0/0 or sqrt(-1)). Since the result is undefined, no comparison to another undefined result makes sense, so all comparisons return False. This can cause silent bugs in data pipelines where NaN values slip through equality checks undetected.
# Dangerous: NaN in a list will never be found by ==
data = [1.0, float('nan'), 3.0]
print(float('nan') in data) # False - uses == under the hood
bool - The Integer Subclass
bool Is a Subclass of int
print(isinstance(True, bool)) # True
print(isinstance(True, int)) # True ← bool IS an int
print(True == 1) # True
print(False == 0) # True
print(True is 1) # False - different object types
bool is defined as a subclass of int in Python's type hierarchy. True and False are the only two instances of bool, and they behave exactly like the integers 1 and 0 in arithmetic contexts:
print(True + True) # 2
print(True * 7) # 7
print(False + False) # 0
print(True + True + False) # 2
# Practical consequence:
flags = [True, True, False, True, False]
print(sum(flags)) # 3 - counts the True values
Why This Design Decision?
Python adopted this design (introduced in Python 2.3, PEP 285) for backward compatibility. Before bool existed, integers 0 and 1 were used for Boolean logic. Making bool a subclass of int preserved that behavior while adding explicit Boolean types. The sum(flags) idiom (counting True values) is a direct benefit.
When Bool-as-Int Causes Bugs
def process(value):
if value == True: # Matches both True AND 1 !
return "boolean true"
elif value == 1:
return "integer one"
return "other"
print(process(True)) # "boolean true"
print(process(1)) # "boolean true" ← probably not intended
# Better: check type explicitly
def process_safe(value):
if type(value) is bool and value:
return "boolean true"
elif type(value) is int and value == 1:
return "integer one"
return "other"
Using == True or == False for conditional checks is an antipattern. Use if value: for truthiness and if value is True: only when you need to distinguish the bool object True from other truthy values like 1 or "hello".
Truthiness and Falsy Values
# Falsy values - evaluate to False in boolean context:
print(bool(0)) # False
print(bool(0.0)) # False
print(bool(0j)) # False (complex zero)
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")) # True - non-empty string
print(bool([0])) # True - non-empty list
Custom classes can define __bool__ (or __len__) to control their truthiness:
class Wallet:
def __init__(self, balance):
self.balance = balance
def __bool__(self):
return self.balance > 0
w = Wallet(0)
if not w:
print("Wallet is empty") # Prints this
NoneType - The Singleton
There Is Exactly One None
None is the sole instance of NoneType. Python guarantees that only one None object exists in the entire interpreter process. Every variable assigned None references the same object:
x = None
y = None
z = None
print(x is y) # True - same object
print(y is z) # True - same object
print(id(x) == id(y) == id(z)) # True
print(type(None)) # <class 'NoneType'>
print(sys.getsizeof(None)) # 16 bytes
Because None is a singleton, identity comparison (is) is both correct and slightly faster than equality comparison (==).
is None vs == None
# Correct:
def find(value, data):
result = None
for item in data:
if item == value:
result = item
break
if result is None: # ← correct
print("Not found")
# Fragile:
if result == None: # ← fragile; a custom __eq__ could make this True
print("Not found")
A custom class can override __eq__ to return True when compared to None, making obj == None unreliable. is None bypasses __eq__ entirely - it directly compares object identity (memory addresses). Since None is a singleton, x is None is definitively True only if x actually is None.
PEP 8 (Python's style guide) explicitly states: "Comparisons to singletons like None should always be done with is or is not, never the equality operators."
None as a Sentinel Value
None is the canonical Python sentinel - a value that signals "no value" or "not yet computed":
def find_user(user_id, cache=None):
"""Returns user dict or None if not found."""
if cache is None:
cache = {}
return cache.get(user_id) # Returns None if key not present
result = find_user(42)
if result is None:
print("User not found, querying database...")
None vs 0 vs False - They Are Different
print(None == 0) # False
print(None == False) # False
print(None == "") # False
print(None is False) # False
print(bool(None)) # False - None is falsy, but it is not False
# All three are falsy, but they mean different things:
value_none = None # "No value / not applicable"
value_zero = 0 # "The number zero"
value_false = False # "The boolean value false"
Conflating these three causes subtle bugs in functions that return 0 or False as valid results:
def get_count(database, key):
# Returns 0 if key exists with count 0, or None if key doesn't exist
return database.get(key) # dict.get returns None if key missing
db = {"events": 0}
count = get_count(db, "events")
# WRONG: treats 0 as "not found"
if not count:
print("No data") # Prints "No data" even though count is 0!
# CORRECT: check explicitly for None
if count is None:
print("Key not found")
else:
print(f"Count: {count}") # Prints "Count: 0"
Type Coercion and Arithmetic Promotion
Python performs implicit type promotion in arithmetic when operands have different numeric types. The promotion hierarchy is:
| Precision (low to high) | Type |
|---|---|
| Least precise | bool |
int | |
float | |
| Most precise | complex |
When operands of different types meet in an expression, the less-precise type is promoted to the more-precise type:
print(True + 1) # 2 (bool → int)
print(1 + 1.5) # 2.5 (int → float)
print(1.5 + 2j) # (1.5+2j) (float → complex)
print(True + 1.0) # 2.0 (bool → float via int → float)
print(type(True + 1)) # <class 'int'>
print(type(1 + 1.5)) # <class 'float'>
print(type(1 + 2j)) # <class 'complex'>
Python does not implicitly coerce strings to numbers or numbers to strings:
print(1 + "1") # TypeError: unsupported operand type(s) for +: 'int' and 'str'
print("2" * 3) # "222" - string repetition, not numeric multiplication
Immutability - Engineering Consequences
All four scalar types are immutable. Once a bool, int, float, or NoneType object is created, its value cannot change. "Reassigning" a variable does not mutate the object - it rebinds the variable name to a different object:
x = 10
y = x # y and x reference the SAME int(10) object
x = 20 # x now references a NEW int(20) object; y still references int(10)
print(y) # 10 - y is unaffected
Immutability Enables Hashing
An object is hashable if its hash value never changes during its lifetime. Since immutable objects cannot change their value, they can safely be hashed. This is why int, float, bool, and None can be used as dictionary keys and set members:
d = {
42: "the answer",
3.14: "pi",
True: "boolean true",
None: "no value",
}
print(d[42]) # "the answer"
print(d[True]) # "the answer" ← True == 1 == 42 is False; True == 1 which hashes same as int 1, not 42
Wait - there is a subtlety:
d = {}
d[True] = "from bool"
d[1] = "from int" # OVERWRITES the True entry - True == 1 and hash(True) == hash(1)
print(d) # {True: "from int"} ← only one entry!
print(len(d)) # 1
True and 1 are equal (True == 1) and have the same hash (hash(True) == hash(1) == 1). Dictionaries use both == and hash() for key lookup. Since they match on both, d[1] = ... updates the existing True key rather than creating a new entry.
Immutability and Thread Safety
Immutable objects are inherently thread-safe for reads. Multiple threads can safely read the same int or str object without synchronization, because no thread can modify it. This is a significant advantage in concurrent code and one reason Python's standard library uses immutable objects extensively as return values and default configurations.
sys.getsizeof - Object Overhead vs C Struct
import sys
# Python scalar sizes (CPython 3.12, 64-bit):
print(sys.getsizeof(None)) # 16 bytes
print(sys.getsizeof(False)) # 24 bytes
print(sys.getsizeof(True)) # 24 bytes (same as False)
print(sys.getsizeof(0)) # 24 bytes
print(sys.getsizeof(1)) # 28 bytes
print(sys.getsizeof(256)) # 28 bytes
print(sys.getsizeof(257)) # 28 bytes
print(sys.getsizeof(2**30)) # 28 bytes
print(sys.getsizeof(2**60)) # 32 bytes - two 30-bit digits
print(sys.getsizeof(3.14)) # 24 bytes - always fixed width
Compare with equivalent C types:
char(bool equivalent): 1 byteint(32-bit): 4 bytesdouble(float equivalent): 8 bytesvoid*(None equivalent): 8 bytes
Python's scalar overhead ranges from 2x to 28x vs C, because every object carries type and reference count metadata. This is an acceptable trade-off for most applications. When memory or speed is critical (numerical computing), use NumPy arrays which store data in compact C arrays without per-element Python overhead.
Pitfalls Summary
Pitfall 1 - Float for Currency
# WRONG: float arithmetic for money
price = 0.10
quantity = 100
total = price * quantity
print(total) # 10.000000000000002 - off by a fraction of a cent
# CORRECT: Decimal for money
from decimal import Decimal
price = Decimal("0.10")
quantity = 100
total = price * quantity
print(total) # 10.00 - exact
Pitfall 2 - NaN Is Never Equal to Anything
import math
nan = float('nan')
data = [1.0, float('nan'), 3.0]
target = float('nan')
# This will never find the NaN:
if target in data:
print("found")
else:
print("not found") # always prints this
# Correct way to detect NaN in data:
has_nan = any(math.isnan(x) for x in data)
print(has_nan) # True
Pitfall 3 - Bool Arithmetic Causing Subtle Bugs
results = [True, False, True, True, False]
# Intended: count True results
count = sum(results) # 3 - this works and is idiomatic
# Unintended: using bool as an index
items = ["zero", "one", "two"]
flag = True
print(items[flag]) # "one" - True == 1 as index; surprising but valid Python
# Unintended: bool in comparison chain
x = 1
print(False < x < True) # False < 1 < 1 → True and False → False
Pitfall 4 - Constructing Decimal from float
from decimal import Decimal
# WRONG: preserves float's binary imprecision
d_wrong = Decimal(0.1)
print(d_wrong) # Decimal('0.1000000000000000055511151231257827021181583404541015625')
# CORRECT: construct from string
d_right = Decimal("0.1")
print(d_right) # Decimal('0.1')
Interview Questions and Answers
Q1: Python has no "primitive" types - explain what that means and what the performance implications are.
A: In Python, every value - including integers, floats, booleans, and None - is a heap-allocated object with a reference count, a pointer to its type object, and its value data. There are no "unboxed" or "stack-allocated" values as in C or Java's primitive types. The performance implication is per-object overhead: a Python int takes 28 bytes versus 4 bytes for a C int. Additionally, every arithmetic operation involves object allocation (for the result), reference counting increments and decrements, and dynamic type dispatch - compared to a single machine instruction in C. For numerical computing at scale, NumPy circumvents this by storing data in contiguous C arrays and vectorizing operations in C, bypassing per-element Python object overhead entirely.
Q2: Explain CPython's small integer cache. Why does a is b return True for a = b = 100 but False for a = b = 1000?
A: CPython pre-allocates a fixed array of int objects for the range [-5, 256] at interpreter startup. Whenever code assigns an integer in this range, CPython returns a reference to the pre-allocated object rather than creating a new one. This means a = 100 and b = 100 both reference the same pre-allocated int(100) object - so a is b is True. For a = b = 1000, each assignment creates a fresh int(1000) object on the heap (because 1000 is outside the cache range), so a and b point to different objects and a is b is False. This is a CPython-specific optimization - other Python implementations may behave differently. The correct takeaway: always use == for value comparison; is is only appropriate for singleton checks like is None, is True, is False.
Q3: Why does 0.1 + 0.2 != 0.3 in Python, and how do you handle floating-point equality in production code?
A: Python's float is a 64-bit IEEE-754 double-precision number. In binary, 0.1 is a repeating fraction (like 1/3 in decimal) and cannot be represented exactly - the nearest representable double is slightly larger than 0.1. The addition of the nearest doubles for 0.1 and 0.2 produces a result that differs from the nearest double for 0.3 by about 5.5 × 10⁻¹⁷. In production code, handle float equality with math.isclose(a, b, rel_tol=1e-9) for general comparisons, abs(a - b) < epsilon with a domain-appropriate epsilon for specific contexts, and decimal.Decimal (constructed from strings) for financial calculations where exact decimal representation is required.
Q4: Why is bool a subclass of int in Python? What are the practical consequences of this design?
A: bool was added in Python 2.3 (PEP 285) as a subclass of int for backward compatibility. Before bool existed, 0 and 1 were Python's canonical false and true values. Making bool a subclass means existing code that tested if x == 1: or used integers in boolean contexts continued to work. The practical consequences include: True + True == 2, sum([True, False, True]) == 2 (idiomatic way to count truthy values), True == 1 and hash(True) == hash(1) (so {True: "a", 1: "b"} has only one key), and isinstance(True, int) returning True. Bugs arise when code uses == True for comparison (which also matches integer 1) or when True/False slip into arithmetic expressions unintentionally.
Q5: Why must you use is None instead of == None, and what is the singleton guarantee of NoneType?
A: None is the only instance of NoneType. Python guarantees exactly one None object exists per interpreter process - it is created at startup and never garbage collected. Because None is a singleton, identity comparison (is) is both semantically precise and reliable: x is None is True if and only if x is the exact None object. Using == None invokes __eq__, which can be overridden by custom classes to return True even when the object is not None. PEP 8 mandates is None and is not None for this reason. The singleton guarantee also makes is None checks slightly faster than == None - it is a pointer comparison rather than a method call.
Q6: What is the difference between None, False, and 0 in Python, and when does conflating them cause bugs?
A: All three are falsy (they evaluate to False in a boolean context), but they carry different semantic meanings: None means "no value / absence"; False means "the boolean false"; 0 means "the integer zero." Conflating them causes bugs when a function can legitimately return 0 or False as a valid result. Classic example: dict.get(key) returns None if the key is missing, but can return 0 or False if those are the stored values. Using if not result: catches all three cases, treating a valid 0 as "not found." The correct test is if result is None:. Similarly, if value == False: matches both False and 0 (since False == 0), which is almost never the intended behavior. Use explicit type checks (type(value) is bool) or is False when the distinction matters.
Graded Practice Challenges
Level 1 - Predict the Output
Challenge: What does this code print? Explain each line.
import sys
a = 256
b = 256
c = 257
d = 257
print(a is b)
print(c is d)
print(True == 1)
print(True is 1)
print(type(True + 0))
print(sys.getsizeof(0) == sys.getsizeof(1))
Show Answer
True
False
True
False
<class 'int'>
False
Explanations:
a is b→True:256is within the small integer cache range[-5, 256]. Bothaandbreference the same pre-allocatedint(256)object.c is d→False:257is outside the cache range. Each assignment creates a newint(257)object on the heap, socanddare distinct objects.True == 1→True:Trueinheritsint.__eq__, and sinceboolis a subclass ofintwithTruehaving the value1, equality holds.True is 1→False:Trueand1are different objects.Trueis theboolsingleton;1is the pre-cachedint(1)object. Despite equal values, they are distinct objects at different memory addresses.type(True + 0)→<class 'int'>:True(abool) added to0(anint) produces anint, not abool. Arithmetic onbooloperands promotes the result toint.sys.getsizeof(0) == sys.getsizeof(1)→False:int(0)is 24 bytes;int(1)is 28 bytes. CPython'sintobject has a header plus digit storage -0requires no digit storage, while any non-zero value requires at least one 30-bit digit slot.
Challenge: What does this code print?
from decimal import Decimal
a = 0.1 + 0.2
b = Decimal("0.1") + Decimal("0.2")
print(a == 0.3)
print(b == Decimal("0.3"))
print(float(b) == 0.3)
print(a == float(b))
Show Answer
False
True
False
True
Explanations:
a == 0.3→False:0.1 + 0.2in float is0.30000000000000004, which is not equal to the float representation of0.3.b == Decimal("0.3")→True:Decimal("0.1") + Decimal("0.2")is computed in exact decimal arithmetic as0.3, which equalsDecimal("0.3").float(b) == 0.3→False: ConvertingDecimal("0.3")to float gives the float representation of0.3, which is0.29999999999999998.... The floatais0.30000000000000004. They are different float values, so this isFalse. (Note: this depends on CPython internals; the result isFalsebecauseDecimal("0.3")converts to the float closest to0.3, and0.1 + 0.2in float is slightly larger.)a == float(b)→True: Botha(0.1 + 0.2in float =0.30000000000000004) andfloat(b)are the same float value because they go through the same IEEE-754 rounding. Actually:float(Decimal("0.3"))gives the float nearest to0.3, which is0.2999999999999999888..., while0.1 + 0.2gives0.3000000000000000444.... These are different, soa == float(b)isFalse. Wait - let us be precise. Running in Python confirms:float(Decimal("0.3")) == 0.3isTrue, and0.1 + 0.2 == 0.3isFalse, soa == float(b)evaluates as0.30000000000000004 == 0.3which isFalse.
Corrected output:
False
True
True
False
The key lesson: Decimal("0.3") is exactly 0.3 in decimal; float(Decimal("0.3")) gives the closest IEEE-754 double to 0.3, which is the same as the float literal 0.3. But 0.1 + 0.2 gives a slightly different double. This demonstrates why mixing Decimal and float is dangerous and why financial code should stay entirely within Decimal.
Level 2 - Debug the Code
Challenge: This function is supposed to return the average of a list of scores. Find and fix the bug.
def compute_average(scores):
total = False
count = 0
for score in scores:
total = total + score
count = count + True
return total / count
scores = [85, 90, 78, 92, 88]
print(compute_average(scores))
Show Answer
The code actually produces the correct numeric result (86.6) because False is 0 and True is 1 in arithmetic. But it contains a serious readability and intent bug: using False as a numeric accumulator and True as an increment counter is misleading and exploits the bool-is-int relationship in a non-obvious way. The code signals wrong intent to any reader.
The bug in intent: If a future developer changes total = False to total = None or total = "" (thinking it means "empty"), the code breaks with a TypeError. Using False as 0 is fragile and unclear.
Fixed version:
def compute_average(scores):
if not scores:
return None # Handle empty list
total = 0 # Clearly an integer accumulator
count = 0
for score in scores:
total += score
count += 1
return total / count
# Even more Pythonic:
def compute_average_clean(scores):
if not scores:
return None
return sum(scores) / len(scores)
scores = [85, 90, 78, 92, 88]
print(compute_average(scores)) # 86.6
print(compute_average_clean(scores)) # 86.6
Lesson: bool and int are arithmetically compatible, but using True/False as integer values in non-boolean contexts is an antipattern that harms code clarity and maintainability.
Level 3 - Design and Explain
Challenge: You are designing a configuration system. A configuration value can be:
- Not yet set (no value)
- Explicitly set to
False(boolean false) - Explicitly set to
0(integer zero) - Explicitly set to
""(empty string)
The system must distinguish between all four states. Design a Python solution using the primitive types covered in this lesson. Explain why the naive if not value: check fails for this use case, and why is None is essential to the design.
Show Answer
Why naive checks fail:
value = False # Explicitly set to False
if not value:
print("Not set") # WRONG: prints "Not set" for False, 0, "", and None
False, 0, "", and None are all falsy. A simple if not value: check cannot distinguish "not set" from "set to a falsy value."
Design using None as a sentinel:
_UNSET = object() # A unique sentinel (even safer than None if None is a valid value)
class ConfigValue:
"""Wraps a configuration value, distinguishing 'unset' from any set value."""
def __init__(self):
self._value = _UNSET # Internal sentinel: "not yet configured"
def set(self, value):
"""Explicitly set the configuration value (including False, 0, or '')."""
self._value = value
def is_set(self):
"""Return True only if a value has been explicitly configured."""
return self._value is not _UNSET
def get(self, default=None):
"""Return the value, or default if not set."""
if self._value is _UNSET:
return default
return self._value
def __repr__(self):
if self._value is _UNSET:
return "ConfigValue(unset)"
return f"ConfigValue({self._value!r})"
# Usage:
cfg = ConfigValue()
print(cfg.is_set()) # False - not yet configured
print(cfg.get("N/A")) # "N/A" - default returned
cfg.set(False)
print(cfg.is_set()) # True - explicitly set
print(cfg.get("N/A")) # False - not the default
cfg.set(0)
print(cfg.is_set()) # True
print(cfg.get("N/A")) # 0
cfg.set("")
print(cfg.is_set()) # True
print(cfg.get("N/A")) # ""
Why is None works for simpler cases:
When None itself is not a valid configuration value, using None as the sentinel directly is idiomatic Python:
def get_config(key, registry={}):
value = registry.get(key) # Returns None if key not in registry
if value is None:
return "not configured"
return value # Could be False, 0, or "" - all distinguished from None
The core principle: Python's None singleton, checked with is None, is the canonical way to represent "absence of a value" while allowing all other Python values (including falsy ones like False, 0, "") to represent "present but empty/zero/false." Any check that uses truthiness (if not value:) collapses this distinction and should only be used when you genuinely want to treat all falsy values the same way.
Quick Reference Cheatsheet
| Type | Key Properties | Watch Out For |
|---|---|---|
int | Arbitrary precision, no overflow | Small int cache: a is b True only for [-5, 256]; use == not is |
int literals | 0xFF (hex), 0o77 (octal), 0b1010 (binary), 1_000_000 (separator) | Underscore is cosmetic only |
float | IEEE-754 64-bit double (~15 sig digits) | 0.1 + 0.2 != 0.3; never use for currency |
float special | float('inf'), float('-inf'), float('nan') | nan != nan is True; use math.isnan() |
float comparison | math.isclose(a, b, rel_tol=1e-9) | Never use == for floats |
Decimal | Exact decimal arithmetic | Construct from strings, not floats: Decimal("0.1") not Decimal(0.1) |
bool | Subclass of int; True==1, False==0 | True + True == 2; {True: x, 1: y} has 1 key |
bool truthiness | False, 0, 0.0, "", [], {}, None are falsy | if not value: cannot distinguish None from False from 0 |
None | Singleton; only instance of NoneType | Use is None not == None; bool(None) is False but None is not False |
sys.getsizeof | int(0): 24B, int(1): 28B, float: 24B, None: 16B | Overhead is 2–28x vs C types |
| Immutability | All scalars are immutable - value cannot change | "Reassignment" rebinds the name, does not mutate the object |
| Hashing | Immutable types are hashable: usable as dict keys and set members | hash(True) == hash(1) == 1; they collide as dict keys |
| Type promotion | bool → int → float → complex in arithmetic | True + 1.0 gives float, not bool |
Key Takeaways
- Python has no "primitive" types in the C sense. Every value - including
42,True, andNone- is a heap-allocated object with type information and reference count overhead. This enables uniformity and dynamism at the cost of memory and speed. intin Python is arbitrary precision - it never overflows, but consumes more memory as values grow. CPython cachesintobjects in[-5, 256]; never useisto compare integers, only==.floatis a 64-bit IEEE-754 double. Binary representation limits mean0.1 + 0.2 != 0.3. Usemath.isclose()for comparisons anddecimal.Decimal(from strings) for financial arithmetic.float('nan')is never equal to anything, including itself. Usemath.isnan()to detect NaN. Failing to handle NaN causes silent bugs in data pipelines.boolis a proper subclass ofint.True == 1,False == 0, andhash(True) == hash(1). Bool arithmetic (sum(flags)to count True values) is idiomatic. Avoid== Truecomparisons.Noneis a singleton - exactly oneNoneobject exists. Always useis Noneandis not Nonefor checks.None,False, and0are all falsy but semantically distinct; conflating them causes bugs in functions that return0orFalseas valid results.- Arithmetic promotion follows
bool → int → float → complex. Python never implicitly coerces strings to numbers. - All scalar types are immutable. "Reassignment" rebinds a name to a new object; the original object is unchanged. Immutability enables safe hashing, dict keys, set membership, and thread-safe reads.
sys.getsizeofreveals Python's object overhead: a Pythonintcosts 24–28+ bytes vs 4 bytes in C. For memory-critical numerical work, use NumPy arrays.
