Skip to main content

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 (-5 to 256), sys.getsizeof showing 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), why 0.1 + 0.2 != 0.3, math.isclose() for safe comparison, decimal.Decimal for financial arithmetic, float('inf'), float('nan'), and the NaN comparison gotcha
  • bool: why it is a subclass of int, what True == 1 and False == 0 means for arithmetic, and when this design causes bugs
  • NoneType: the singleton guarantee, why you must use is None instead of == None, None as a sentinel value, and the distinction between None, 0, and False
  • 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).

note

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
warning

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): 0 for positive, 1 for 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
danger

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"
warning

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.

tip

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 precisebool
int
float
Most precisecomplex

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 byte
  • int (32-bit): 4 bytes
  • double (float equivalent): 8 bytes
  • void* (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 bTrue: 256 is within the small integer cache range [-5, 256]. Both a and b reference the same pre-allocated int(256) object.
  • c is dFalse: 257 is outside the cache range. Each assignment creates a new int(257) object on the heap, so c and d are distinct objects.
  • True == 1True: True inherits int.__eq__, and since bool is a subclass of int with True having the value 1, equality holds.
  • True is 1False: True and 1 are different objects. True is the bool singleton; 1 is the pre-cached int(1) object. Despite equal values, they are distinct objects at different memory addresses.
  • type(True + 0)<class 'int'>: True (a bool) added to 0 (an int) produces an int, not a bool. Arithmetic on bool operands promotes the result to int.
  • sys.getsizeof(0) == sys.getsizeof(1)False: int(0) is 24 bytes; int(1) is 28 bytes. CPython's int object has a header plus digit storage - 0 requires 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.3False: 0.1 + 0.2 in float is 0.30000000000000004, which is not equal to the float representation of 0.3.
  • b == Decimal("0.3")True: Decimal("0.1") + Decimal("0.2") is computed in exact decimal arithmetic as 0.3, which equals Decimal("0.3").
  • float(b) == 0.3False: Converting Decimal("0.3") to float gives the float representation of 0.3, which is 0.29999999999999998.... The float a is 0.30000000000000004. They are different float values, so this is False. (Note: this depends on CPython internals; the result is False because Decimal("0.3") converts to the float closest to 0.3, and 0.1 + 0.2 in float is slightly larger.)
  • a == float(b)True: Both a (0.1 + 0.2 in float = 0.30000000000000004) and float(b) are the same float value because they go through the same IEEE-754 rounding. Actually: float(Decimal("0.3")) gives the float nearest to 0.3, which is 0.2999999999999999888..., while 0.1 + 0.2 gives 0.3000000000000000444.... These are different, so a == float(b) is False. Wait - let us be precise. Running in Python confirms: float(Decimal("0.3")) == 0.3 is True, and 0.1 + 0.2 == 0.3 is False, so a == float(b) evaluates as 0.30000000000000004 == 0.3 which is False.

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

TypeKey PropertiesWatch Out For
intArbitrary precision, no overflowSmall int cache: a is b True only for [-5, 256]; use == not is
int literals0xFF (hex), 0o77 (octal), 0b1010 (binary), 1_000_000 (separator)Underscore is cosmetic only
floatIEEE-754 64-bit double (~15 sig digits)0.1 + 0.2 != 0.3; never use for currency
float specialfloat('inf'), float('-inf'), float('nan')nan != nan is True; use math.isnan()
float comparisonmath.isclose(a, b, rel_tol=1e-9)Never use == for floats
DecimalExact decimal arithmeticConstruct from strings, not floats: Decimal("0.1") not Decimal(0.1)
boolSubclass of int; True==1, False==0True + True == 2; {True: x, 1: y} has 1 key
bool truthinessFalse, 0, 0.0, "", [], {}, None are falsyif not value: cannot distinguish None from False from 0
NoneSingleton; only instance of NoneTypeUse is None not == None; bool(None) is False but None is not False
sys.getsizeofint(0): 24B, int(1): 28B, float: 24B, None: 16BOverhead is 2–28x vs C types
ImmutabilityAll scalars are immutable - value cannot change"Reassignment" rebinds the name, does not mutate the object
HashingImmutable types are hashable: usable as dict keys and set membershash(True) == hash(1) == 1; they collide as dict keys
Type promotionbool → int → float → complex in arithmeticTrue + 1.0 gives float, not bool

Key Takeaways

  • Python has no "primitive" types in the C sense. Every value - including 42, True, and None - is a heap-allocated object with type information and reference count overhead. This enables uniformity and dynamism at the cost of memory and speed.
  • int in Python is arbitrary precision - it never overflows, but consumes more memory as values grow. CPython caches int objects in [-5, 256]; never use is to compare integers, only ==.
  • float is a 64-bit IEEE-754 double. Binary representation limits mean 0.1 + 0.2 != 0.3. Use math.isclose() for comparisons and decimal.Decimal (from strings) for financial arithmetic.
  • float('nan') is never equal to anything, including itself. Use math.isnan() to detect NaN. Failing to handle NaN causes silent bugs in data pipelines.
  • bool is a proper subclass of int. True == 1, False == 0, and hash(True) == hash(1). Bool arithmetic (sum(flags) to count True values) is idiomatic. Avoid == True comparisons.
  • None is a singleton - exactly one None object exists. Always use is None and is not None for checks. None, False, and 0 are all falsy but semantically distinct; conflating them causes bugs in functions that return 0 or False as 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.getsizeof reveals Python's object overhead: a Python int costs 24–28+ bytes vs 4 bytes in C. For memory-critical numerical work, use NumPy arrays.
© 2026 EngineersOfAI. All rights reserved.