Algorithms · Live

Number to Words.

Type a whole number and get it back in plain English, all the way up to 999 trillion.

  • Whole numbers
  • Up to 999 trillion
  • Commas welcome

Try: 21 1,001 123,456 7 billion

1,001

One Thousand and One

Start again

How it works

Three kinds of number, one recursive idea.

  1. 01

    Unique words

    Numbers whose names can't be built from others, like one, eleven, twenty or ninety. They live in a lookup table.

  2. 02

    Derived words

    Names built from unique ones. Twenty-one is "twenty" plus "one".

  3. 03

    Mixed words

    Combinations with a scale word, like "one hundred", "four thousand" or "seven billion".

Recursion

The number is split at its largest scale (trillion, billion, million, thousand, hundred), and each part is converted by the same function. The parts get smaller until every one is a word in the lookup table.

A 28-word lookup table

Zero to twenty and the tens (thirty to ninety) are the only words stored. Everything else, up to 999 trillion, is built from them, with no external libraries.

Documentation

How I solved it, step by step.

The thinking behind the tool, from scribbled examples to tested code. Each step shows the question I asked, what I noticed, and the code that came out of it.

  1. 01

    Write numbers out by hand first

    Question: before writing any code, how do people actually say numbers?

    I wrote a handful out longhand and looked for what repeats and what doesn't.

    • Some words are irregular: "Thirteen", not "Three-Teen"; "Twelve", not "Ten-Two".
    • Above twenty, numbers are built: Forty-Five = Forty + Five.
    • "Thousand", "Million" and "Billion" just repeat the 1–999 pattern with a label.
    7            Seven                 irregular
    13           Thirteen              irregular
    45           Forty-Five            tens + units
    300          Three Hundred         digit + "Hundred"
    45,300       Forty-Five Thousand,  (45) Thousand
                 Three Hundred           + (300)
    2,045,300    Two Million, ...      (2) Million + (45,300)
  2. 02

    Find the smallest set of words

    Question: which words can't be built from others?

    Only 0–20 and the tens (30, 40 … 90). That's 28 words. Every other number up to 999 trillion is assembled from these plus "Hundred" and the scale names.

    Decision: store just those 28 in a dictionary keyed by number.

    UNIQUE = {
        0: 'Zero', 1: 'One', 2: 'Two', ... 19: 'Nineteen',
        20: 'Twenty', 30: 'Thirty', 40: 'Forty', 50: 'Fifty',
        60: 'Sixty', 70: 'Seventy', 80: 'Eighty', 90: 'Ninety',
    }
    
    def _words(n: int) -> str:
        if n in UNIQUE:          # 0-20 and the tens
            return UNIQUE[n]
  3. 03

    Two digits: split tens and units

    Question: how do I get "Forty-Five" from 45?

    Integer division does it in one step: divmod(45, 10) gives (4, 5). Multiply the tens back by ten to look up "Forty", then look up "Five".

    Decision: join with a hyphen, as standard English does.

        if n < 100:
            tens, units = divmod(n, 10)
            return f'{UNIQUE[tens * 10]}-{UNIQUE[units]}'
    
    # _words(45) -> 'Forty-Five'
  4. 04

    Three digits: hundreds and the first recursion

    Question: 345 is "Three Hundred" plus something. What is that something?

    It's 45, which step 3 already solves. So instead of new logic, the function calls itself on the remainder. That's the first use of recursion.

    Decision: British English puts "and" after Hundred, but only when something follows.

        if n < 1000:
            hundreds, rest = divmod(n, 100)
            head = f'{UNIQUE[hundreds]} Hundred'
            return f'{head} and {_words(rest)}' if rest else head
    
    # _words(345) -> 'Three Hundred and Forty-Five'
    # _words(300) -> 'Three Hundred'
  5. 05

    Big numbers: the same problem, smaller

    Question: do I need separate code for thousands, millions and billions?

    No. 2,045,300 is 2 × Million + 45,300, and 45,300 is 45 × Thousand + 300. Each piece is a smaller version of the same problem, so one rule handles every scale: split at the largest scale present and convert both halves with _words.

    Decision: list the scales largest first, so the first match is always the right split.

    SCALES = [
        (10 ** 12, 'Trillion'),
        (10 ** 9, 'Billion'),
        (10 ** 6, 'Million'),
        (10 ** 3, 'Thousand'),
    ]
    
        for size, name in SCALES:
            if n >= size:
                head, rest = divmod(n, size)
                text = f'{_words(head)} {name}'   # recursion
                ...
    
    # 2,045,300 -> _words(2) Million + _words(45,300)
    #           -> _words(45) Thousand + _words(300)
  6. 06

    Decide how the pieces join

    Question: "One Thousand, One" or "One Thousand and One"?

    I checked how British English, as used in Nigeria and Canada, writes these:

    • 1,100 → "One Thousand, One Hundred"
    • 1,001 → "One Thousand and One"
    • 1,000,099 → "One Million and Ninety-Nine"

    Decision: use a comma between scale groups, or "and" when the final part is under 100. If nothing is left over, stop.

                if not rest:
                    return text                    # 2,000,000 -> 'Two Million'
                joiner = ' and ' if rest < 100 else ', '
                return f'{text}{joiner}{_words(rest)}'
  7. 07

    Guard the edges

    Question: what will real people type into the box?

    I tried to break it: 1,000, 007, -5, 12.5, ², an empty box, and a 5,000-digit number. The core function should only ever see a clean, valid integer.

    Decision: one normalise() function cleans the input or raises a clear error. Length is checked before converting, so huge inputs stay cheap.

    LIMIT = 10 ** 15   # up to 999,999,999,999,999
    
    def normalise(figure) -> int:
        ...  # (excerpt: the string branch; int input is checked separately)
        digits = figure.replace(',', '').replace(' ', '')
        if not digits:
            raise ValueError('Please enter a number.')
        digits = digits.lstrip('0') or '0'
        if not digits.isascii() or not digits.isdigit():
            raise ValueError('Whole numbers only: use the digits 0-9, ...')
        if len(digits) > len(str(LIMIT - 1)):
            raise OverflowError('Limit exceeded: ... 999 trillion.')
        return int(digits)
    
    # '1,000' -> 1000    '007' -> 7    '²' -> ValueError
  8. 08

    Prove it, don't just eyeball it

    Question: a few examples look right, but are all of them right?

    Checking a million outputs by eye is impossible. So I wrote a separate, simple parser that turns words back into numbers. If parse(convert(n)) == n for every number, the converter is right.

    Result: every number from 0 to 1,000,000, plus 18,000 random numbers up to the limit, round-trips perfectly.

    # simplified from core/tests.py
    def words_to_number(text):
        total = group = 0
        for token in re.split(r'[\s,-]+', text.lower()):
            if token == 'and':
                continue
            if token in SMALL:   group += SMALL[token]
            elif token == 'hundred': group *= 100
            elif token in SCALE:
                total += group * SCALE[token]
                group = 0
        return total + group
    
    def test_exhaustive_to_one_million(self):
        for number in range(1_000_001):
            words = convert(number)
            self.assertEqual(words_to_number(words), number)
  9. 09

    Look back: what version 1 got wrong

    Question: what did the first attempt teach me?

    • It sliced strings (figure[1:]), so leading zeros like 007 crashed it. Version 2 works on integers with divmod.
    • It had a separate branch for every range. Version 2 has one rule for all scales.
    • It printed "Twenty One" and "One Thousand, and One". Version 2 follows one written style.
    • It claimed memoization it didn't have. Version 2 only describes what it does.
    # version 1: one branch per range, string slicing
    elif int(figure) >= 1e6 and int(figure) < 1e15:
        if int(figure) < 1e9:
            X = (6, 'Million')
        ...
        last_digits = figure[-X[0]:]
        front_digits = figure[:-X[0]]
    
    # version 2: one rule for every scale
    head, rest = divmod(n, size)

Style rules at a glance

RuleInputOutput
Tens and units are hyphenated21Twenty-One
"and" after Hundred101One Hundred and One
Scale groups separated by commas1,100One Thousand, One Hundred
"and" before a final group under 1001,001One Thousand and One
Empty groups are skipped2,000,050,000Two Billion, Fifty Thousand

Input & limits

  • Accepted: whole numbers from 0 to 999,999,999,999,999.
  • Allowed formatting: commas, spaces and leading zeros.
  • Rejected: negatives, decimals, letters, 1e3 and non-ASCII digits, each with a clear message.

Using the function

from core.tinyproject.numtoword import convert

convert(1001)         # 'One Thousand and One'
convert('1,000,099')  # 'One Million and Ninety-Nine'
convert('-5')         # ValueError
convert(10 ** 15)     # OverflowError
Full source: numtoword.pyShow
"""Convert a whole number (0 to 999 trillion) into English words.

Style: British English, as used in Nigeria and Canada.

    21          -> Twenty-One
    101         -> One Hundred and One
    1001        -> One Thousand and One
    1100        -> One Thousand, One Hundred
    1000099     -> One Million and Ninety-Nine
    123456      -> One Hundred and Twenty-Three Thousand, Four Hundred and Fifty-Six

Rules:
  * Tens and units are hyphenated (Twenty-One).
  * "and" follows Hundred when something comes after it (One Hundred and Five).
  * Scale groups (Thousand, Million, ...) are separated by commas, except that the
    final group is joined with "and" when it is below one hundred.

The algorithm is recursive: a number is split at its largest scale into
`head x scale + rest`, and both parts are converted by the same function until
every piece is a word in the 28-entry lookup table below.
"""

LIMIT = 10 ** 15  # exclusive: the largest supported value is 999,999,999,999,999

UNIQUE = {
    0: 'Zero', 1: 'One', 2: 'Two', 3: 'Three', 4: 'Four', 5: 'Five', 6: 'Six',
    7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten', 11: 'Eleven', 12: 'Twelve',
    13: 'Thirteen', 14: 'Fourteen', 15: 'Fifteen', 16: 'Sixteen', 17: 'Seventeen',
    18: 'Eighteen', 19: 'Nineteen', 20: 'Twenty', 30: 'Thirty', 40: 'Forty',
    50: 'Fifty', 60: 'Sixty', 70: 'Seventy', 80: 'Eighty', 90: 'Ninety',
}

# largest first, so the split always happens at the biggest scale present
SCALES = [
    (10 ** 12, 'Trillion'),
    (10 ** 9, 'Billion'),
    (10 ** 6, 'Million'),
    (10 ** 3, 'Thousand'),
]


def _words(n: int) -> str:
    """Words for 0 <= n < LIMIT. Recursive on the largest scale present."""
    if n in UNIQUE:
        return UNIQUE[n]

    if n < 100:
        tens, units = divmod(n, 10)
        return f'{UNIQUE[tens * 10]}-{UNIQUE[units]}'

    if n < 1000:
        hundreds, rest = divmod(n, 100)
        head = f'{UNIQUE[hundreds]} Hundred'
        return f'{head} and {_words(rest)}' if rest else head

    for size, name in SCALES:
        if n >= size:
            head, rest = divmod(n, size)
            text = f'{_words(head)} {name}'
            if not rest:
                return text
            joiner = ' and ' if rest < 100 else ', '
            return f'{text}{joiner}{_words(rest)}'

    raise AssertionError('unreachable')  # every n >= 1000 matches a scale


def normalise(figure) -> int:
    """Turn user input into an int, or raise.

    Accepts an int, or a string of ASCII digits with optional commas, spaces and
    leading zeros ("1,000", " 007 "). Raises ValueError for anything else (signs,
    decimals, letters, non-ASCII digits) and OverflowError at or above LIMIT.
    """
    if isinstance(figure, bool):
        raise ValueError('Whole numbers only.')
    if isinstance(figure, int):
        number = figure
    elif isinstance(figure, str):
        digits = figure.replace(',', '').replace(' ', '')
        if not digits:
            raise ValueError('Please enter a number.')
        digits = digits.lstrip('0') or '0'
        if not digits.isascii() or not digits.isdigit():
            raise ValueError('Whole numbers only: use the digits 0-9, optionally with commas.')
        if len(digits) > len(str(LIMIT - 1)):  # checked before int() so huge input stays cheap
            raise OverflowError('Limit exceeded: the largest supported number is 999 trillion.')
        number = int(digits)
    else:
        raise ValueError('Whole numbers only.')

    if number < 0:
        raise ValueError('Negative numbers are not supported.')
    if number >= LIMIT:
        raise OverflowError('Limit exceeded: the largest supported number is 999 trillion.')
    return number


def convert(figure) -> str:
    """Return `figure` in British English words, e.g. convert('1,001') -> 'One Thousand and One'.

    :param figure: an int, or a string of digits (commas, spaces and leading zeros allowed)
    :return: the number in words, Title Case
    :raises ValueError: if the input is not a non-negative whole number
    :raises OverflowError: if the number is 1,000 trillion or more
    """
    return _words(normalise(figure))