skip to content

Why is an int accepted where a parameter is annotated float?

level: middleimportance: must knowfreq 58%

answer

  1. A convention, not a class hierarchy
  2. Written into the typing PEP
  3. Runs in one direction only
  4. int, then float, then complex
  5. isinstance(2, float) is still False

basics

~10 s

PEP 484 special-cases the numeric tower: an int is acceptable where float is annotated, and both where complex is. It is a type-checker convention, not subclassing, and nothing is converted at runtime.

solid answer

~40 s

PEP 484 defines an **implicit promotion** for the three built-in numeric types: an argument typed `int` satisfies a parameter annotated `float`, and `int` or `float` satisfies `complex`. It exists so nobody has to write `int | float` on every numeric parameter. Crucially it is **duck-type compatibility invented for checkers**, not subclassing — `isinstance(2, float)` is `False`, and the promotion runs in one direction only, so passing a `float` where `int` is annotated is an error. Nothing converts: after `x: float = 2`, `x` is still an `int` object at runtime. That gap bites when the body calls a float-only method such as `float.hex()` or does true division assumptions, so a function that genuinely needs a `float` value should call `float()` on it at the boundary. `decimal.Decimal` and `fractions.Fraction` are outside the rule entirely.

code

python · 5 lines
python
print(isinstance(2, float), isinstance(True, int))
x: float = 2
print(type(x))
print(2 + 0.5, type(2 + 0.5))
print((3).is_integer())

go deeper

for a junior

Recall the acceptance itself and its direction: an int may be given where float is annotated, but not the reverse. Knowing that much, plus that nothing is converted, is enough at this level.

for a middle

Explain the mechanism: PEP 484 writes the int-float-complex promotion in as a special case because there is no subclass relation, and back it with isinstance(2, float) being False.

for a senior

Demonstrate the production consequence — the object really is still an int, so float-only methods and serialization can fail on paths that only some inputs reach — and describe coercing at the boundary when the body needs a real float.

for a principal

Own the API convention: decide when your codebase annotates float and coerces, when it spells out int or float to make the branch visible, and how exact numeric types such as Decimal cross module boundaries.

## The rule PEP 484 contains a short but consequential paragraph on the numeric tower. Rather than force every numeric API to be annotated `int | float`, the PEP declares that when a parameter is annotated `float`, an argument of type `int` is acceptable; when it is annotated `complex`, both `int` and `float` are acceptable. Every mainstream checker implements it. Combined with `bool` being a real subclass of `int`, the acceptance chain is `bool` → `int` → `float` → `complex`. The promotion is **asymmetric**. A `float` where `int` is annotated is a hard error, and rightly so — it would be lossy. ## It is a typing fiction, not a runtime relationship The single most common misconception is that this reflects a class hierarchy. It does not: ```pycon >>> isinstance(2, float) False >>> float in int.__mro__ False >>> x: float = 2 >>> type(x) <class 'int'> ``` Annotations do not run and do not convert. The variable annotated `float` holds an `int` object for the whole life of the program. Arithmetic hides this most of the time, because mixing an `int` with a `float` produces a `float` and `/` always produces a `float`. The disguise fails wherever the code reaches for something only `float` has. ## The failure this produces in real code Consider a sensor-telemetry collector whose devices report a per-sample clock offset. The correction helper is annotated for floats and formats the offset exactly for a log line: ```python def drift_seconds(offset: float) -> str: return offset.hex() ``` Every checker accepts `drift_seconds(3)`. At runtime it raises `AttributeError: 'int' object has no attribute 'hex'`, because `hex` is a method on `float`, not on `int`. One device family in the fleet emits whole-second offsets as integers; the clock-skew artefact it produces only shows up on that hardware, so the crash survives an entire three-week release train before anyone hits it in production. The fix is one call — `float(offset).hex()` — but the lesson is the general one: **the promotion guarantees the argument is numerically usable, not that it is a `float` object.** Other places the gap shows: `//` and `%` on ints give int results with different edge behaviour from float results; `math` functions return floats regardless, so the asymmetry sometimes cancels out; serializing to JSON writes `3` rather than `3.0`; and formatting with `str()` differs. Python 3.12 added `int.is_integer()`, precisely so that one common float-only method stopped being a landmine for promoted ints. ## What the rule deliberately excludes * **`decimal.Decimal` and `fractions.Fraction`** are not part of the promotion. They are registered with the `numbers` ABCs at runtime, but statically they are ordinary unrelated classes, so passing a `Decimal` where `float` is annotated is an error. That is a *good* error: mixing `Decimal` and `float` arithmetic raises `TypeError` for some operations and silently loses precision in others. * **`str` and `bytes`** get no analogous shortcut; the promotion is numeric-only and intentionally so. * **Return types.** The rule applies to assignability generally, so a function annotated `-> float` may return an `int` and the checker is satisfied — which pushes the same surprise onto the caller. ## How to annotate deliberately * Annotate `float` when the body only does arithmetic — this is the common case, and the promotion is exactly the convenience it was designed to be. * Annotate `float` but **coerce at the boundary** with `value = float(value)` when the body calls float-only methods, hands the value to a C-level API expecting a double, or stores it in something whose serialized form matters. * Annotate `int | float` explicitly when the difference genuinely changes behaviour and you want readers to see it. * Annotate `int` when the value is a count, an index or an identifier; the asymmetry then protects you, because a `float` will be rejected. ## What an interviewer wants Name PEP 484 and the direction of the promotion, immediately deny the subclass reading with `isinstance(2, float) is False`, and then show that you know the practical consequence: the object stays an `int`, so anything relying on it being a real `float` at runtime can still fail. Candidates who stop after "int is a kind of float" have got the accepted behaviour right and the mechanism wrong.

  • Does the same rule let you pass a float where int is annotated?
    No — the promotion is strictly one-directional, `int` → `float` → `complex`. Passing a `float` where `int` is annotated is an error, because narrowing would be lossy: `3.7` has no faithful integer form and indexes, counts and identifiers must be exact. If a value legitimately arrives as a float and must become an index, convert deliberately with `int()`, `round()` or `math.floor()` so the truncation policy is visible in the code.
  • Why is decimal.Decimal rejected where float is annotated, when it is obviously a number?
    The promotion names exactly `int`, `float` and `complex`; `Decimal` participates in the `numbers` ABCs only through runtime registration, which checkers do not follow. The rejection is useful rather than annoying: `Decimal` and `float` do not mix freely — some operations raise `TypeError` and others silently drop the exactness that motivated using `Decimal` in the first place. Convert explicitly, and only at a boundary where the precision loss is intended.
  • What breaks if a function annotated -> float actually returns an int?
    The checker accepts it, so every caller is told it holds a `float` while the object is an `int`. Callers that only do arithmetic are fine; callers that call `.hex()`, `.as_integer_ratio()` with float semantics, format with a float-specific spec, or serialize the value will see integer behaviour. If the return value crosses an API boundary, coerce with `float(...)` before returning so the annotation and the object agree.

It is like a venue that accepts a student card as proof of age: the card is honoured at the door, but it never becomes a passport, so the moment something demands passport-only details it fails.

saying these in an interview costs you the question

  • Saying int is a subclass of float
  • Believing the annotation converts the value to a float
  • Assuming the promotion also works float to int
  • Expecting Decimal to be accepted where float is annotated
  • Claiming isinstance(2, float) returns True
  • Thinking the rule is CPython behaviour rather than a checker convention

context