How Python Compares Float and Int Objects: A Deep Dive into CPython Internals

Arpit Bhayani

Arpit Bhayani

May 13, 2021 • 6 min read

Play

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

  1. LOAD_CONST 1 (10.0) pushes the float constant onto the evaluation stack.
  2. LOAD_CONST 2 (10) pushes the integer constant onto the evaluation stack.
  3. 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.

The Immediate Short-Circuit for Equality

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

  1. The float has an exact integer representation.
  2. CPython converts the integral double value (ipart) into a temporary Python integer (PyLongObject) via _PyLong_FromDouble(ipart) or equivalent internal integer-building routines.
  3. CPython then invokes the arbitrary-precision integer comparison function (long_compare) between the converted float-integer and the target integer w.
  4. Both values are now compared at full precision digit-by-digit, eliminating rounding and overflow hazards.

5. Summary of Architectural Takeaways

  1. 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.
  2. 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.
  3. 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.
  4. Robust Type Safety: Mixed-type operations maintain strict mathematical correctness rather than convenience-driven type coercion.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses