Algorithms · Live
Number to Words.
Type a whole number and get it back in plain English, all the way up to 999 trillion.
Your number in words will appear here.
How it works
Three kinds of number, one recursive idea.
- 01
Unique words
Numbers whose names can't be built from others, like one, eleven, twenty or ninety. They live in a lookup table.
- 02
Derived words
Names built from unique ones. Twenty-one is "twenty" plus "one".
- 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.
-
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) -
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] -
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' -
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' -
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) -
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)}' -
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 -
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)) == nfor 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) -
09
Look back: what version 1 got wrong
Question: what did the first attempt teach me?
- It sliced strings (
figure[1:]), so leading zeros like007crashed it. Version 2 works on integers withdivmod. - 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) - It sliced strings (
Style rules at a glance
| Rule | Input | Output |
|---|---|---|
| Tens and units are hyphenated | 21 | Twenty-One |
| "and" after Hundred | 101 | One Hundred and One |
| Scale groups separated by commas | 1,100 | One Thousand, One Hundred |
| "and" before a final group under 100 | 1,001 | One Thousand and One |
| Empty groups are skipped | 2,000,050,000 | Two 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,
1e3and 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))