Representing Rational Numbers With Python Fractions

Representing Rational Numbers With Python Fractions

by Bartosz Zaczyński Updated Reading time estimate 48m basics python stdlib

The fractions module in Python is arguably one of the most underused elements of the standard library. Even though it may not be well-known, it’s a useful tool to have under your belt because it can help address the shortcomings of floating-point arithmetic in binary. That’s essential if you plan to work with financial data or if you require infinite precision for your calculations.

Towards the end of this tutorial, you’ll see a few hands-on examples where fractions are the most suitable and elegant choice. You’ll also learn about their weaknesses and how to make the best use of them along the way.

In this tutorial, you’ll learn how to:

  • Convert between decimal and fractional notation
  • Perform rational number arithmetic
  • Approximate irrational numbers
  • Represent fractions exactly with infinite precision
  • Know when to choose Fraction over Decimal or float

The majority of this tutorial goes over the fractions module, which in itself doesn’t require in-depth Python knowledge other than an understanding of its numeric types. However, you’ll be in a good place to work through all the code examples that follow if you’re familiar with more advanced concepts such as Python’s built-in collections module, itertools module, and generators. You should already be comfortable with these topics if you want to make the most out of this tutorial.

Decimal vs Fractional Notation

Let’s take a walk down memory lane to bring back your school knowledge of numbers and avoid possible confusion. There are four concepts at play here:

  1. Types of numbers in mathematics
  2. Numeral systems
  3. Notations of numbers
  4. Numeric data types in Python

You’ll get a quick overview of each of these now to better understand the purpose of the Fraction data type in Python.

Classification of Numbers

If you don’t remember the classification of numbers, here’s a quick refresher:

The Main Types of Numbers in Mathematics
Types of Numbers

There are many more types of numbers in mathematics, but these are the most relevant in day-to-day life. At the very top, you’ll find complex numbers that include imaginary and real numbers. Real numbers are, in turn, comprised of rational and irrational numbers. Finally, rational numbers contain integers and natural numbers.

Numeral Systems and Notations

There have been various systems of expressing numbers visually over the centuries. Today, most people use a positional numeral system based on Hindu-Arabic symbols. You can choose any base or radix for such a system. However, while people prefer the decimal system (base-10), computers work best in the binary system (base-2).

Within the decimal system itself, you can represent some numbers using alternative notations:

  • Decimal: 0.75
  • Fractional: ¾

Neither of these is better or more precise than the other. Expressing a number in decimal notation is perhaps more intuitive because it resembles a percentage. Comparing decimals is also more straightforward since they already have a common denominator—the base of the system. Finally, decimal numbers can communicate precision by keeping the trailing and leading zeros.

On the other hand, fractions are more convenient in performing symbolic algebra by hand, which is why they’re mainly used in school. But can you recall the last time you used fractions? If you can’t, then that’s because decimal notation is central in calculators and computers nowadays.

The fractional notation is typically associated with rational numbers only. After all, the very definition of a rational number states that you can express it as a quotient, or a fraction, of two integers as long as the denominator is nonzero. However, that’s not the whole story when you factor in infinite continued fractions that can approximate irrational numbers:

Decimal vs Fractional Notation

Irrational numbers always have a non-terminating and non-repeating decimal expansion. For example, the decimal expansion of pi (π) never runs out of digits that seem to have a random distribution. If you were to plot their histogram, then each digit would have a roughly similar frequency.

On the other hand, most rational numbers have a terminating decimal expansion. However, some can have an infinite recurring decimal expansion with one or more digits repeated over a period. The repeated digits are commonly denoted with an ellipsis (0.33333…) in the decimal notation. Regardless of their decimal expansion, rational numbers such as the number representing one-third always look elegant and compact in the fractional notation.

Numeric Data Types in Python

Numbers with infinite decimal expansions cause rounding errors when stored as a floating-point data type in computer memory, which itself is finite. To make matters worse, it’s often impossible to exactly represent numbers with terminating decimal expansion in binary!

That’s known as the floating-point representation error, which affects all programming languages, including Python. Every programmer faces this problem sooner or later. For example, you can’t use float in applications like banking, where numbers must be stored and acted on without any loss of precision.

Python’s Fraction is one of the solutions to these obstacles. While it represents a rational number, the name Rational already represents an abstract base class in the numbers module. The numbers module defines a hierarchy of abstract numeric data types to model the classification of numbers in mathematics:

Type Hierarchy for Numbers in Python
Type hierarchy for numbers in Python

Fraction is a direct and concrete subclass of Rational, which provides the complete implementation for rational numbers in Python. Integral types like int and bool also derive from Rational, but those are more specific.

With this theoretical background out of the way, it’s time to create your first fraction!

Creating a Python Fraction From Different Data Types

Unlike int or float, fractions aren’t a built-in data type in Python, which means you have to import a corresponding module from the standard library to use them. However, once you get past this extra step, you’ll find that fractions just represent another numeric type that you can freely mix with other numbers and mathematical operators in arithmetic expressions.

There are a few ways to create a fraction in Python, and they all involve using the Fraction class. It’s the only thing you ever need to import from the fractions module. The class constructor accepts zero, one, or two arguments of various types:

Language: Python
>>> from fractions import Fraction
>>> print(Fraction())
0
>>> print(Fraction(0.75))
3/4
>>> print(Fraction(3, 4))
3/4

When you call the class constructor without arguments, it creates a new fraction representing the number zero. A single-argument flavor attempts to convert the value of another data type to a fraction. Passing in a second argument makes the constructor expect a numerator and a denominator, which must be instances of the Rational class or its descendants.

Note that you must print() a fraction to reveal its human-friendly textual representation with the slash character (/) between the numerator and the denominator. If you don’t, it will fall back to a slightly more explicit string representation made up of a piece of Python code. You’ll learn how to convert fractions to strings later in this tutorial.

Rational Numbers

When you call the Fraction() constructor with two arguments, they must both be rational numbers such as integers or other fractions. If either the numerator or denominator isn’t a rational number, then you won’t be able to create a new fraction:

Language: Python
>>> Fraction(3, 4.0)
Traceback (most recent call last):
  ...
    raise TypeError("both arguments should be "
TypeError: both arguments should be Rational instances

You get a TypeError instead. Although 4.0 is a rational number in mathematics, it isn’t considered as such by Python. That’s because the value is stored as a floating-point data type, which is too broad and can be used to represent any real number.

Similarly, you can’t create a fraction whose denominator is zero because that would lead to a division by zero, which is undefined and has no meaning in mathematics:

Language: Python
>>> Fraction(3, 0)
Traceback (most recent call last):
  ...
    raise ZeroDivisionError('Fraction(%s, 0)' % numerator)
ZeroDivisionError: Fraction(3, 0)

Python raises the ZeroDivisionError. However, when you specify a valid numerator and a valid denominator, they’ll be automatically normalized for you as long as they have a common divisor:

Language: Python
>>> Fraction(9, 12)  # GCD(9, 12) = 3
Fraction(3, 4)

>>> Fraction(0, 12)  # GCD(0, 12) = 12
Fraction(0, 1)

Both magnitudes get simplified by their greatest common divisor (GCD), which happens to be three and twelve, respectively. The normalization also takes the minus sign into account when you define negative fractions:

Language: Python
>>> -Fraction(9, 12)
Fraction(-3, 4)

>>> Fraction(-9, 12)
Fraction(-3, 4)

>>> Fraction(9, -12)
Fraction(-3, 4)

Whether you put the minus sign before the constructor or before either of the arguments, for consistency, Python will always associate the sign of a fraction with its numerator. There’s currently a way of disabling this behavior, but it’s undocumented and could get removed in the future.

You’ll typically define fractions as a quotient of two integers. Whenever you provide only one integer, Python will turn that number into an improper fraction by assuming the denominator is 1:

Language: Python
>>> Fraction(3)
Fraction(3, 1)

Conversely, if you skip both arguments, the numerator will be 0:

Language: Python
>>> Fraction()
Fraction(0, 1)

You don’t always have to provide integers for the numerator and denominator, though. The documentation states that they can be any rational numbers, including other fractions:

Language: Python
>>> one_third = Fraction(1, 3)

>>> Fraction(one_third, 3)
Fraction(1, 9)

>>> Fraction(3, one_third)
Fraction(9, 1)

>>> Fraction(one_third, one_third)
Fraction(1, 1)

In each case, you’ll get a fraction as a result, even though they sometimes represent integer values like 9 and 1. Later, you’ll see how to convert fractions to other data types.

What happens if you give the Fraction constructor a single argument that also happens to be a fraction? Try this code to find out:

Language: Python
>>> Fraction(one_third) == one_third
True

>>> Fraction(one_third) is one_third
False

You’re getting the same value, but it’s a distinct copy of the input fraction. That’s because calling the constructor always produces a new instance, which coincides with the fact that fractions are immutable, just like other numeric types in Python.

Floating-Point and Decimal Numbers

So far, you’ve only used rational numbers to create fractions. After all, the two-argument version of the Fraction constructor requires that both numbers are Rational instances. However, that’s not the case with the single-argument constructor, which will happily accept any real number and even a non-numeric value such as a string.

Two prime examples of real number data types in Python are float and decimal.Decimal. While only the latter can represent rational numbers exactly, both can approximate irrational numbers just fine. Related to this, if you were wondering, Fraction is similar to Decimal in this regard since it’s a descendant of Real.

Unlike float or Fraction, the Decimal class isn’t formally registered as a subclass of numbers.Real despite implementing its methods:

Language: Python
>>> from numbers import Real
>>> issubclass(float, Real)
True

>>> from fractions import Fraction
>>> issubclass(Fraction, Real)
True

>>> from decimal import Decimal
>>> issubclass(Decimal, Real)
False

That’s intentional since decimal floating-point numbers don’t play well with their binary counterparts:

Language: Python
>>> from decimal import Decimal
>>> Decimal("0.75") - 0.25
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for -: 'decimal.Decimal' and 'float'

On the other hand, replacing Decimal with an equivalent Fraction would yield a float result in the example above.

Before Python 3.2, you could only create fractions from real numbers using the .from_float() and .from_decimal() class methods. While not deprecated, they’re redundant today because the Fraction constructor can take both data types directly as an argument:

Language: Python
>>> from decimal import Decimal
>>> Fraction(0.75) == Fraction(Decimal("0.75"))
True

Whether you make Fraction objects from float or Decimal objects, their values are the same. You’ve seen a fraction created from a floating-point value before:

Language: Python
>>> print(Fraction(0.75))
3/4

The result is the same number expressed in fractional notation. However, this code works as expected only by coincidence. In most cases, you won’t get the intended value due to the representation error that affects float numbers, whether they’re rational or not:

Language: Python
>>> print(Fraction(0.1))
3602879701896397/36028797018963968

Whoa! What happened here?

Let’s break it down in slow motion. The previous number, which can be represented as either 0.75 or ¾, can also be expressed as the sum of ½ and ¼, which are negative powers of 2 that have exact binary representations. On the other hand, the number ⅒ can only be approximated with a non-terminating repeating expansion of binary digits:

The Binary Expansion of One Tenth

Because the binary string must eventually end due to the finite memory, its tail gets rounded. By default, Python only shows the most significant digits defined in sys.float_info.dig, but you can format a floating-point number with an arbitrary number of digits if you want to:

Language: Python
>>> str(0.1)
'0.1'

>>> format(0.1, ".17f")
'0.10000000000000001'
>>> format(0.1, ".18f")
'0.100000000000000006'
>>> format(0.1, ".19f")
'0.1000000000000000056'
>>> format(0.1, ".55f")
'0.1000000000000000055511151231257827021181583404541015625'

When you pass a float or a Decimal number to the Fraction constructor, it calls their .as_integer_ratio() method to obtain a tuple of two irreducible integers whose ratio gives precisely the same decimal expansion as the input argument. These two numbers are then assigned to the numerator and denominator of your new fraction.

Now, you can piece together where these two big numbers came from:

Language: Python
>>> Fraction(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

If you pull out your pocket calculator and punch these numbers in, then you’ll get back 0.1 as a result of the division. However, if you were to divide them by hand or use a tool like WolframAlpha, then you’d end up with those fifty-five decimal places you saw earlier.

There is a way to find close approximations of your fraction that have more down-to-earth values. You can use .limit_denominator(), for example, which you’ll learn more about later in this tutorial:

Language: Python
>>> one_tenth = Fraction(0.1)
>>> one_tenth
Fraction(3602879701896397, 36028797018963968)

>>> one_tenth.limit_denominator()
Fraction(1, 10)

>>> one_tenth.limit_denominator(max_denominator=int(1e16))
Fraction(1000000000000000, 9999999999999999)

This might not always give you the best approximation, though. The bottom line is that you should never try to create fractions straight from real numbers such as float if you want to avoid the rounding errors that will likely come up. Even the Decimal class might be susceptible to that if you’re not careful enough.

Anyway, fractions let you communicate the decimal notation most accurately with a string in their constructor.

Strings

There are two string formats that the