How Python Compares Float and Int Objects: A Deep Dive into CPython Internals
In Python, evaluating an expression such as 10.0 == 10 returns True, while 10.2 == 10 returns False. While this behavior seems intuitive on the surface, the underlying mechanics are far from trivial.
Python float objects are implemented as 64-bit IEEE 754 double-precision floating-point numbers, whereas int objects are arbitrary-precision integers (bignums) that can grow as large as memory permits. Naively casting a large integer to a float causes silent precision loss, while naively casting a float to an integer truncates fractional components.
This deep dive traces the execution path from Python bytecode down to CPython’s C source code (ceval.c and Objects/floatobject.c) to uncover how Python handles mixed-type comparisons safely and accurately.
1. Inspecting the Bytecode with dis
To understand where Python starts evaluating a comparison, we can disassemble a simple function comparing a float and an integer using the standard library dis module:
import dis
def compare():
return 10.0 == 10
dis.dis(compare)
The disassembly produces the following bytecode instructions:
4 0 LOAD_CONST 1 (10.0)
2 LOAD_CONST 2 (10)
4 COMPARE_OP 2 (==)
6 RETURN_VALUE
Bytecode Breakdown
LOAD_CONST 1 (10.0) pushes the float constant onto the evaluation stack.
LOAD_CONST 2 (10) pushes the integer constant onto the evaluation stack.
COMPARE_OP 2 (==) pops both operands, runs the comparison operation, and pushes the boolean result.
In CPython’s main interpreter loop (Python/ceval.c), the evaluation switch statement handles COMPARE_OP by calling PyObject_RichCompare.
2. The Rich Comparison Protocol
CPython uses a unified comparison protocol known as Rich Comparison. Every built-in type defines a function pointer in its type definition struct (PyTypeObject) named tp_richcompare.
The rich comparison function signature is:
PyObject *tp_richcompare(PyObject *v, PyObject *w, int op);
Where:
v is the left-hand operand.
w is the right-hand operand.
op is the comparison operator opcode (e.g., Py_LT, Py_LE, Py_EQ, Py_NE, Py_GT, Py_GE).
Dispatch Mechanics in do_richcompare
When PyObject_RichCompare(v, w, op) is called:
flowchart TD
Start[PyObject_RichCompare v, w, op] --> CheckTypes{Are types different and w is subclass of v?}
CheckTypes -- Yes --> TryRightSubclass[Call w->tp_richcompare with swapped op]
CheckTypes -- No --> TryLeft[Call v->tp_richcompare v, w, op]
TryLeft --> CheckResult{Did v return NotImplemented?}
CheckResult -- No --> ReturnResult[Return Comparison Result]
CheckResult -- Yes --> TryRight[Call w->tp_richcompare w, v, swapped_op]
TryRight --> CheckRightResult{Did w return NotImplemented?}
CheckRightResult -- No --> ReturnResult
CheckRightResult -- Yes --> DefaultIdentity[Fallback: Identity / Pointer Comparison]
When evaluating 10.0 == 10, v is of type float and w is of type int. Because int is not a subclass of float, CPython invokes the left operand’s comparison method: float_richcompare.
3. Inside float_richcompare (Objects/floatobject.c)
The type object for floats, PyFloat_Type, binds tp_richcompare to float_richcompare. Let’s analyze how this function routes comparisons.
Case 1: Float vs. Float
If both operands are floats (PyFloat_Check(w) is true), the comparison is trivial:
- Extract the underlying C
double values using PyFloat_AS_DOUBLE.
- Run a direct hardware comparison in C (
i = (i < j), i == j, etc.).
- Wrap the result in
Py_True or Py_False.
Case 2: Float vs. Long/Int
If the second operand w is an integer (PyLong_Check(w) is true), Python cannot simply cast the integer to a double. Standard IEEE 754 64-bit floats have only 53 bits of precision (significand). A Python integer can have hundreds or thousands of bits. Converting a large integer to double would truncate significant bits and yield incorrect comparison results.
Similarly, converting the float to an integer directly would discard the fractional part, making 10.2 == 10 evaluate to True.
To handle this, float_richcompare applies an optimized three-step algorithm:
flowchart TD
A[Compare Float v and Int w] --> B{Is Float v Finite?}
B -- No: inf / nan --> C[Handle Infinite / NaN Logic]
B -- Yes --> D[Check Signs of v and w]
D -- Signs Differ --> E[Resolve based on Opcode e.g., negative < positive]
D -- Signs Match --> F[Extract Integer and Fractional Parts using modf]
F --> G{Fractional Part != 0?}
G -- Yes and Op is EQ --> H[Return False]
G -- Yes and Op is NE --> I[Return True]
G -- No: Pure Integer Float --> J[Convert Float to PyLong and Compare Integers]
4. Deconstructing the Comparison Logic
Let’s trace how the C implementation tackles each phase of the mixed-type comparison.
Step 1: Sign Verification
Before inspecting magnitude, CPython checks whether the signs match:
- If
v < 0.0 and the integer w is non-negative, v is strictly smaller than w.
- If
v > 0.0 and the integer w is negative, v is strictly greater than w.
If the signs differ, equality (==) is immediately False, and inequality (<, >, <=, >=) can be determined without analyzing individual digits.
Step 2: Fractional Decomposition with modf
If both numbers share the same sign, CPython calls the C standard library function modf on the float:
double ipart;
double fpart = modf(v_double, &ipart);
modf splits a floating-point number into:
ipart: The integral component stored as a double.
fpart: The fractional component stored as a double.
An integer has no fractional part. Therefore, if fpart != 0.0:
- The float can never be equal to the integer.
- If the comparison operator is
Py_EQ (==), CPython instantly returns Py_False.
- If the comparison operator is
Py_NE (!=), CPython instantly returns Py_True.
This explains why 10.2 == 10 exits almost immediately after modf without touching the arbitrary-precision integer comparison routines.
Step 3: Exact Integer-to-Integer Comparison
When fpart == 0.0 (as in 10.0 == 10):
- The float has an exact integer representation.
- CPython converts the integral double value (
ipart) into a temporary Python integer (PyLongObject) via _PyLong_FromDouble(ipart) or equivalent internal integer-building routines.
- CPython then invokes the arbitrary-precision integer comparison function (
long_compare) between the converted float-integer and the target integer w.
- Both values are now compared at full precision digit-by-digit, eliminating rounding and overflow hazards.
5. Summary of Architectural Takeaways
- No Silent Precision Sacrifices: CPython does not cast
int to float because integers can exceed the 53-bit mantissa limit of 64-bit IEEE 754 floats.
- Early Short-Circuiting: Checking signs and checking for a non-zero fractional component (
modf) allows CPython to resolve common comparisons (such as 10.2 == 10) without allocating bignum objects.
- Symmetric Delegation: If an object cannot compare itself against an unknown type (returns
Py_NotImplemented), CPython flips the operator (< becomes >, == stays ==) and hands control to the right operand’s tp_richcompare.
- Robust Type Safety: Mixed-type operations maintain strict mathematical correctness rather than convenience-driven type coercion.