A function that accepts a number and a function that parses text are not quite the same API.

I keep running into this distinction in configuration code, pricing tools, import pipelines, and small libraries. The caller may already have an int, float, Decimal, or another numeric object. In that case, accepting a string such as "0.25" just because it happens to look numeric can make the boundary less clear than it needs to be.

Python 3.14 adds Fraction.from_number(), which gives that boundary a useful standard-library API.

from decimal import Decimal
from fractions import Fraction

print(Fraction.from_number(3))
print(Fraction.from_number(0.5))
print(Fraction.from_number(Decimal("0.25")))

The results are exact rational values:

3
1/2
1/4

The interesting part is not that Python gained another way to construct a Fraction. It is that from_number() explicitly accepts numeric inputs while rejecting strings.

The regular constructor also parses text

The normal Fraction constructor is intentionally broad.

from fractions import Fraction

assert Fraction("3/4") == Fraction(3, 4)
assert Fraction("0.75") == Fraction(3, 4)

That is convenient when parsing a file, command-line argument, form field, or another text-oriented input.

But suppose I am writing a library function whose contract says the caller must provide an already interpreted numeric value:

from fractions import Fraction


def calculate_share(value):
    fraction = Fraction(value)
    return fraction / 100

This function quietly accepts both of these calls:

calculate_share(25)
calculate_share("25")

Maybe that is desirable. Often it is not.

If text parsing belongs to a different layer, accepting the second call makes malformed application boundaries harder to notice. A request handler may forget to validate or convert input, yet deeper domain code still appears to work.

With Python 3.14, I can express the narrower contract directly:

from fractions import Fraction


def calculate_share(value):
    fraction = Fraction.from_number(value)
    return fraction / 100

Now numeric values are accepted, while a string is rejected:

calculate_share(25)       # Fraction(1, 4)
calculate_share("25")     # TypeError

To be fair, this is a small difference in code. It can be a useful difference in API design.

What from_number() accepts

Fraction.from_number() accepts the standard numeric cases you would expect, including integral and rational values, float, Decimal, and objects exposing as_integer_ratio().

from decimal import Decimal
from fractions import Fraction

values = [
    7,
    Fraction(2, 3),
    0.125,
    Decimal("1.25"),
]

for value in values:
    print(Fraction.from_number(value))

This produces:

7
2/3
1/8
5/4

The important exclusion is text:

from fractions import Fraction

Fraction.from_number("1/8")

That raises TypeError rather than parsing the string.

I like to think of the two entry points this way:

from fractions import Fraction

# Parsing boundary: text is intentionally allowed.
ratio_from_input = Fraction("12.5")

# Numeric boundary: the value should already be numeric.
ratio_from_domain = Fraction.from_number(12.5)

The resulting arithmetic may be identical, but the contracts communicate different intent.

Floats are converted exactly, not prettified

One trap remains unchanged: converting a float to a fraction preserves the exact binary floating-point value.

from fractions import Fraction

value = Fraction.from_number(0.1)
print(value)

Do not expect that result to mean exactly 1/10.

The decimal literal 0.1 is first represented as a binary float; Fraction.from_number() then represents that actual float exactly as a rational number.

This matters when the distinction between a measurement and a decimal quantity is important.

For decimal business values, I would normally convert from Decimal instead:

from decimal import Decimal
from fractions import Fraction

price = Fraction.from_number(Decimal("0.1"))
assert price == Fraction(1, 10)

If I genuinely have a floating-point measurement and want a simpler nearby rational approximation, limit_denominator() is a separate operation:

from fractions import Fraction

measured = Fraction.from_number(0.1)
approximation = measured.limit_denominator(100)

assert approximation == Fraction(1, 10)

Keeping those operations separate is useful. Exact conversion tells me what value I actually have; approximation expresses a policy I chose afterward.

Python 3.14 broadens the numeric protocol

There is another related Python 3.14 change that I find more interesting than the constructor itself: Fraction can now consume arbitrary objects that provide as_integer_ratio().

Here’s the idea. A custom numeric type can expose its exact value as two integers without inheriting from Python’s numeric abstract base classes.

class BasisPoints:
    def __init__(self, value: int):
        self.value = value

    def as_integer_ratio(self):
        return self.value, 10_000

Then it can participate in fraction conversion:

from fractions import Fraction

fee = BasisPoints(25)
ratio = Fraction.from_number(fee)

assert ratio == Fraction(1, 400)

That is a pleasantly small protocol. The producer explains its exact integer ratio, while Fraction handles normalization and rational arithmetic.

A type representing fixed-point values can use the same approach:

class FixedPoint:
    def __init__(self, units: int, scale: int):
        if scale <= 0:
            raise ValueError("scale must be positive")
        self.units = units
        self.scale = scale

    def as_integer_ratio(self):
        return self.units, self.scale


from fractions import Fraction

value = FixedPoint(125, 100)
assert Fraction.from_number(value) == Fraction(5, 4)

This is useful for application-specific numeric objects because conversion does not need to go through float and lose information.

Treat as_integer_ratio() as a real contract

A protocol this small is easy to implement badly.

Python expects as_integer_ratio() to describe the value with integer numerator and denominator. For objects used with Fraction, the documented contract assumes a positive denominator and a ratio already in lowest terms.

I would therefore keep the implementation boring and test its invariants explicitly:

from math import gcd


class BasisPoints:
    def __init__(self, value: int):
        self.value = value

    def as_integer_ratio(self):
        numerator = self.value
        denominator = 10_000
        divisor = gcd(numerator, denominator)
        return numerator // divisor, denominator // divisor

For 25 basis points, that method returns (1, 400) rather than (25, 10000).

A custom numeric type should also avoid surprising side effects in this method. Conversion code tends to look harmless:

fraction = Fraction.from_number(value)

If value.as_integer_ratio() performs network access, mutates shared state, or depends on unstable external data, that simple line becomes much harder to reason about.

For my own types, I treat as_integer_ratio() as a pure value operation.

Structural support is useful, but it is not validation

Because Python 3.14 accepts objects through as_integer_ratio(), a caller does not necessarily need a specific inheritance hierarchy.

That flexibility is handy for interoperability, but I would not confuse it with trust.

Consider an object supplied by plugin code:

class StrangeNumber:
    def as_integer_ratio(self):
        print("side effect")
        return 1, 2

Passing it to Fraction.from_number() invokes application-defined code.

So if values cross a trust boundary, validate the allowed types before conversion rather than assuming a standard-library constructor makes arbitrary objects safe:

from decimal import Decimal
from fractions import Fraction

ALLOWED_TYPES = (int, float, Decimal, Fraction)


def trusted_fraction(value):
    if not isinstance(value, ALLOWED_TYPES):
        raise TypeError(f"unsupported numeric type: {type(value).__name__}")

    return Fraction.from_number(value)

This is deliberately stricter than the protocol. That can be exactly what an API exposed to plugins or untrusted application extensions needs.

Inside trusted domain code, accepting the protocol may be the better design.

Separate parsing from numeric conversion

The cleanest use case for from_number() is a layered application.

Imagine an HTTP endpoint receiving a decimal percentage as text. I can parse that input at the edge:

from decimal import Decimal, InvalidOperation


def parse_percentage(raw: str) -> Decimal:
    try:
        value = Decimal(raw)
    except InvalidOperation as exc:
        raise ValueError("invalid percentage") from exc

    if not Decimal("0") <= value <= Decimal("100"):
        raise ValueError("percentage must be between 0 and 100")

    return value

The domain layer can then require numeric input:

from fractions import Fraction


def percentage_ratio(value):
    return Fraction.from_number(value) / 100

Usage stays explicit:

percentage = parse_percentage("12.5")
ratio = percentage_ratio(percentage)

assert ratio == Fraction(1, 8)

This split has a practical debugging advantage. If raw text reaches percentage_ratio(), I get an error at the layer boundary instead of silently parsing it again.

That is the main reason I would choose from_number() over the regular constructor in reusable domain APIs.

Be deliberate about booleans

Python’s bool is a subclass of int, so numeric APIs can accept True and False in places where that may look odd.

If a boolean would be a domain error, reject it explicitly:

from fractions import Fraction


def ratio(value):
    if isinstance(value, bool):
        raise TypeError("boolean is not a valid ratio")

    return Fraction.from_number(value)

This is not specific to Fraction.from_number(). It is a general consequence of Python’s numeric type hierarchy, and it is worth remembering when a numeric API represents money, rates, dimensions, or identifiers where boolean coercion would hide a bug.

Do not use it as a generic parser

I would avoid wrapping from_number() in fallback logic that defeats its purpose:

from fractions import Fraction


def convert(value):
    try:
        return Fraction.from_number(value)
    except TypeError:
        return Fraction(value)

Now strings are accepted again, and unsupported objects may accidentally enter the parsing path.

If an application intentionally accepts either numbers or text, say so directly:

from fractions import Fraction


def parse_or_convert(value):
    if isinstance(value, str):
        return Fraction(value)

    return Fraction.from_number(value)

That version makes the dual contract visible during review and gives me one obvious place to add string-specific validation or limits.

Compatibility before Python 3.14

Fraction.from_number() is new in Python 3.14, so libraries supporting older Python releases need a fallback.

If the goal is merely to reject strings while preserving the older constructor’s numeric behavior, a small compatibility function is often enough:

from fractions import Fraction


def fraction_from_number(value):
    if isinstance(value, str):
        raise TypeError("expected a numeric value, not text")

    if hasattr(Fraction, "from_number"):
        return Fraction.from_number(value)

    return Fraction(value)

There is an important limitation here: Python 3.14 also broadened Fraction support for arbitrary objects implementing as_integer_ratio(). Older Python versions do not necessarily accept the same custom objects.

If cross-version protocol compatibility matters, handle that contract yourself rather than pretending the runtimes behave identically:

from fractions import Fraction


def fraction_from_ratio_protocol(value):
    if isinstance(value, str):
        raise TypeError("expected a numeric value, not text")

    if hasattr(Fraction, "from_number"):
        return Fraction.from_number(value)

    method = getattr(value, "as_integer_ratio", None)
    if method is not None:
        numerator, denominator = method()
        return Fraction(numerator, denominator)

    return Fraction(value)

For a public library, I would add tests on every supported Python version rather than relying on one compatibility branch to capture all numeric edge cases.

Test the boundary, not only the arithmetic

The arithmetic is the easy part. The useful tests describe what the API is supposed to accept.

from decimal import Decimal
from fractions import Fraction


def normalize_ratio(value):
    if isinstance(value, bool):
        raise TypeError("boolean is not a valid ratio")
    return Fraction.from_number(value)


def test_accepts_integer():
    assert normalize_ratio(2) == Fraction(2, 1)


def test_accepts_decimal_exactly():
    assert normalize_ratio(Decimal("0.2")) == Fraction(1, 5)


def test_preserves_float_value_exactly():
    assert normalize_ratio(0.5) == Fraction(1, 2)


def test_rejects_text():
    try:
        normalize_ratio("0.5")
    except TypeError:
        pass
    else:
        raise AssertionError("expected TypeError")


def test_rejects_boolean():
    try:
        normalize_ratio(True)
    except TypeError:
        pass
    else:
        raise AssertionError("expected TypeError")

For a custom as_integer_ratio() type, I would additionally test negative values, zero, normalization, very large integers, and invalid implementations.

Those tests document the real API more clearly than a test that only checks 1/2 + 1/2 == 1.

When I would use from_number()

I would reach for it when a function accepts already parsed numeric values and I want that contract to be visible in code. It is particularly useful in domain models, calculation libraries, adapters between numeric types, and internal APIs where accidentally accepting text would hide a missing validation step.

I would keep using the regular Fraction(...) constructor when parsing strings is intentionally part of the job.

The distinction is simple:

Fraction("3/5")                 # parse text
Fraction.from_number(value)     # convert a numeric value

Python has always made it easy to be permissive. Fraction.from_number() gives us a small way to be more precise.

In the end, that is what I like about this addition. It does not introduce new arithmetic. It gives numeric code a clearer boundary, while the expanded as_integer_ratio() support makes that boundary flexible enough for custom exact numeric types without forcing everything through float.