All articles
Converters
38 min readBy DevUtilX Team

Roman Numerals: History and How to Convert Them in Code

Learn how Roman numerals evolved, the rules of standard form, and how to convert and validate them in JavaScript and Python, with a tested 1 to 3999 round trip.

Roman Numerals: History and How to Convert Them in Code

Roman numerals look simple until you have to write code for them. A function that turns 1994 into MCMXCIV is a fun exercise, but a function that also rejects IIII, IC and VX, refuses 4000, copes with lowercase input and survives a fancy Unicode character pasted from a word processor is real engineering. This guide is for developers, students and anyone who has ever stared at a movie's copyright year and wondered what it says. You will learn where the symbols came from, what the rules really are, how to convert in both directions with tested JavaScript and Python, how to validate input with a regular expression, and where the traps hide.

What are Roman numerals?

Roman numerals are a numeral system that writes numbers with letters of the Latin alphabet. Seven letters carry values, and a number is spelled by combining them. There is no place value, no zero and no digit that changes meaning depending on its position in the way the 1 in 10 and the 1 in 100 do. Instead, the value of a numeral comes from adding the symbols together, with one exception: a smaller symbol placed before a larger one is subtracted.

The seven symbols are these.

Symbol Value Mnemonic note
I 1 A single stroke
V 5 Often explained as an open hand
X 10 Two V shapes joined, or a crossed tally
L 50 Half of C in some early forms
C 100 Commonly linked to the Latin word for hundred
D 500 Half of an old form of M
M 1000 Commonly linked to the Latin word for thousand

The mnemonic column is deliberately cautious. Scholars agree that the system grew out of tally marks, but the origin of individual letters is debated, and the tidy stories you meet in textbooks are partly later rationalisation. We will return to that in the history section.

A short history, with honest uncertainty

The broad outline is well established. Roman numerals were used in ancient Rome, they stayed in everyday use across much of medieval Europe, and they were gradually displaced for calculation and record keeping as the Hindu-Arabic numeral system spread. The details are where scholarship gets careful, so this section separates what is solid from what is conjecture.

From tally marks to symbols

Counting by notches or strokes is among the oldest ways to record a quantity. A row of vertical strokes is the natural ancestor of I, II and III. Most accounts place the origin of Roman numerals in a tally tradition shared with neighbouring peoples in Italy, often named as the Etruscans, rather than in a design invented by Romans. The exact route is not fully documented, and you should treat any claim of a single inventor or date with suspicion.

Counting strokes becomes impractical past a handful, so tally systems introduce a new mark for a group. A popular explanation says V represents a hand with the thumb spread from the fingers, and X represents two such shapes back to back, or a stroke crossed to mark ten. This is plausible and widely repeated, but it is an interpretation, not a documented fact.

The origin of L, C, D and M

The higher symbols are murkier still. Early forms did not look like the letters we use today. Some accounts suggest that the symbols for 50, 100 and 1000 started as Greek letters or as variations on shapes such as a circled cross, and that they were later reinterpreted as the Latin letters L, C and M because those letters happened to match the first letter of a Latin word. The tidy mnemonic that C stands for the Latin word for hundred and M for the word for thousand matches the later usage, yet it is better described as a convenient later association than as the proven origin.

One form is worth knowing because Unicode preserves it. An older way of writing 1000 looked like a circle with a vertical bar through it, and 500 looked like half of that shape. Later scribes flattened these into M and D. This is why the Unicode block contains characters such as ↀ, ↁ and ↂ, which we examine later.

Additive first, subtractive later and inconsistently

The earliest numerals were mostly additive. Four was IIII, nine was VIIII, and forty was XXXX. Subtractive notation, where IV means one before five, existed in antiquity but was used irregularly. Inscriptions show both styles, sometimes in the same text. There was no single authority enforcing the modern rule set, and nothing in the surviving evidence suggests that Roman writers saw IIII as an error.

The tidy rule set taught in schools today, with its fixed list of six subtractive pairs, is largely a product of later convention. It became the standard because it is compact and unambiguous, not because the Romans mandated it. Keep that in mind when a user complains that a clock face "has the wrong numeral". The clock follows a legitimate tradition, and the school rule follows a different one.

Decline and survival

Medieval Europe kept using Roman numerals for dates, chapter numbers, accounts and inscriptions long after other systems existed. Written Roman numerals are awkward for arithmetic, because there are no columns to line up. Merchants and scholars often calculated on a counting board or abacus and wrote only the result. The spread of Hindu-Arabic numerals is commonly associated with Fibonacci's early thirteenth century book Liber Abaci, which taught the system to a European merchant audience, though adoption took centuries and was uneven.

The missing zero

Roman numerals have no symbol for zero. This is not an oversight in the code you will write. It reflects what the system was for. A numeral recorded a quantity of something that exists, such as legions, days or coins, so a count of nothing did not need a number. Zero as a number you can calculate with, and as a placeholder inside positional notation, belongs to a different tradition.

Medieval writers who needed to express "none" in tables used a Latin word for nothing, and some used the letter N as an abbreviation. Scholars debate how systematic this was, and it never became part of the numeral system itself. For programmers the consequence is simple: there is no Roman numeral for 0, and a correct converter must reject it.

Fractions

Roman fractions, used in money and measures, were a separate duodecimal system built around twelfths, with its own symbols. They are beyond the scope of an integer converter, and the standard algorithms below do not handle them. If you see a historic source with dots and curved signs next to the letters, it is probably using this fractional notation.

The rules of standard form

Modern "standard form" is a set of rules that makes every integer from 1 to 3999 have exactly one spelling. That uniqueness is what makes conversion code possible.

Rule 1: Symbols add

When symbols appear in non-increasing order from left to right, add their values. XVI is 10 + 5 + 1 = 16. MMXXIV is 1000 + 1000 + 10 + 10 + 5 - 1, which we write as 2024 once we handle the subtractive pair in the last two letters.

Rule 2: Six subtractive pairs

A smaller symbol directly before a larger one is subtracted, but only six pairs are allowed.

Pair Value Computation
IV 4 5 - 1
IX 9 10 - 1
XL 40 50 - 10
XC 90 100 - 10
CD 400 500 - 100
CM 900 1000 - 100

The pattern is that only I, X and C can be subtracted, each only from the next two symbols up. I subtracts from V and X. X subtracts from L and C. C subtracts from D and M. Nothing else is permitted, so VX is not 5, IL is not 49 and IC is not 99.

Rule 3: Repetition limits

I, X, C and M may repeat, up to three times in a row. V, L and D never repeat, because two of them could always be written as the next symbol up: VV is X, LL is C and DD is M. The rule that four repeats are forbidden is why the standard spelling of 4 is IV, not IIII.

Rule 4: Write by place value

Read a standard numeral as groups of thousands, hundreds, tens and ones, in that order. Each group uses a fixed pattern.

Digit Ones (I, V, X) Tens (X, L, C) Hundreds (C, D, M)
0 (nothing) (nothing) (nothing)
1 I X C
2 II XX CC
3 III XXX CCC
4 IV XL CD
5 V L D
6 VI LX DC
7 VII LXX DCC
8 VIII LXXX DCCC
9 IX XC CM

Thousands are written with M repeated up to three times. This table is the key insight behind the whole system: Roman numerals are a decimal system in disguise. Each decimal digit is written independently, using its own trio of symbols, and the pieces are concatenated from largest to smallest. Zero digits are simply skipped, which is how the system gets away without a zero symbol.

A worked example: 1994

Split 1994 into digits: 1, 9, 9, 4.

  • The thousands digit 1 gives M.
  • The hundreds digit 9 gives CM.
  • The tens digit 9 gives XC.
  • The ones digit 4 gives IV.

Concatenate them and you get MCMXCIV. Going the other way, scan MCMXCIV and group it as M, CM, XC, IV, which is 1000 + 900 + 90 + 4 = 1994.

A second example: 3888

The longest standard numeral under 4000 is 3888, written MMMDCCCLXXXVIII, which is 15 characters. Every group uses the maximum repeats: three M, then DCCC, then LXXX, then VIII. This is useful for sizing a fixed-width field: a standard numeral never exceeds 15 characters, a fact the round-trip test below confirms by checking every value.

Valid and invalid numerals

The question "is this a valid Roman numeral?" has two defensible answers, depending on whether you accept historical variants or only standard form. Code needs to pick one explicitly.

Clock faces and IIII

Many clock and watch dials show four o'clock as IIII rather than IV. This is a real and long-standing convention. Explanations offered for it include visual balance with the VIII on the opposite side of the dial, avoiding confusion between IV and VI when the clock is read upside down or from an angle, and simple tradition among clockmakers. Which explanation is correct, or whether several apply, is something historians of horology still discuss, so treat the stories you hear as possibilities.

For a programmer, the lesson is that IIII is not nonsense. It is a historically attested additive spelling. A converter targeting clocks, inscriptions or historical text may want a lenient mode. A converter targeting forms, outlines and databases should reject it and return the canonical IV.

IC versus XCIX

IC is intuitive to a beginner: 100 minus 1 is 99. It breaks the rule that I may be subtracted only from V and X. The standard spelling of 99 is XCIX, which is 90 + 9, written as XC followed by IX. If you remember the place-value view, this is natural: 99 has a tens digit of 9 and a ones digit of 9, and each digit is written independently.

Other tempting-but-invalid spellings fall into the same family.

Invalid Intended Standard Why invalid
IIII 4 IV Four repeats of I
VIIII 9 IX Four repeats of I
IL 49 XLIX I cannot subtract from L
IC 99 XCIX I cannot subtract from C
VX 5 V V cannot be subtracted
XM 990 CMXC X cannot subtract from M
IIX 8 VIII Only one subtracted symbol is allowed
MMMM 4000 out of range Exceeds standard form
DD 1000 M D cannot repeat

Other ways to be wrong

A few more invalid inputs come up in practice. Mixed case such as Xiv is not a different numeral, but whether you accept it is a policy choice. Embedded whitespace, a trailing period (as in an outline heading IV.) and Unicode look-alikes all need handling before validation. The empty string is not a numeral, a fact that bites when a regular expression happily matches it, as we will see.

Range limits: 1 to 3999 and beyond

Standard form tops out at 3999, written MMMCMXCIX. The reason is mechanical: M may repeat only three times, and there is no symbol larger than M in the basic alphabet. 4000 would need MMMM, which breaks the repetition rule. The bottom of the range is 1, since there is no zero and no negative numbers.

The vinculum

To write larger numbers, a long-standing convention places a horizontal bar over a numeral to multiply its value by 1000. This bar is called a vinculum. With it, a bar over V means 5000, a bar over X means 10,000, and a bar over M means one million. Under this extension, 4000 can be written as IV with a bar over it, and 3,999,999 becomes feasible.

You will meet this in discussions of the system, and it appears in some historical and modern contexts. It is much less standardised than the basic form. Different sources disagree about whether bars may be combined with ordinary letters in the same numeral, and some historical inscriptions used other devices such as framing a numeral on three sides to multiply it by a hundred thousand. Plain text has no overline, so software that wants this extension has to choose a representation: a combining overline character, a markup convention or a separate multiplier field. Because there is no consensus, most converters simply document the range limit and stop at 3999.

The apostrophus system

Another historical device used shapes like CIↃ for 1000, IↃ for 500 and CCIↃↃ for 10,000. Wrapping a symbol in C and a reversed C multiplied it by ten, and adding another reversed C on one side added five times as much again. This notation is an appealing puzzle and is the origin of several Unicode characters, but it is rarely supported in software, and you should not expect users to type it.

What a converter should do with 4000

When a user enters an out-of-range number, you have three honest options:

  1. Reject it with a clear error that states the supported range.
  2. Produce a long run of M characters, which is sometimes seen in lenient tools, and which is non-standard.
  3. Support an explicit extension such as the vinculum and document it.

Silent wrong output is the only truly bad choice. If a number is not representable in the form you promise, say so.

Algorithm 1: integer to Roman with a greedy value table

Converting from an integer to a Roman numeral is a textbook application of a greedy algorithm. At each step, take the largest value that still fits, emit its symbol and subtract. The trick that makes the greedy approach correct is to include the subtractive pairs in the table as if they were symbols. With thirteen entries, ordered from largest to smallest, the algorithm has no special cases at all.

The value table

Value Symbol
1000 M
900 CM
500 D
400 CD
100 C
90 XC
50 L
40 XL
10 X
9 IX
5 V
4 IV
1 I

Tracing 1994

Start with 1994 and an empty result.

  1. 1000 fits, emit M, remainder 994.
  2. 900 fits, emit CM, remainder 94.
  3. 500, 400 and 100 do not fit. 90 fits, emit XC, remainder 4.
  4. 50, 40, 10, 9 and 5 do not fit. 4 fits, emit IV, remainder 0.

The output is MCMXCIV. For 2024, the table gives MM, then skips down to XX, then IV, for MMXXIV.

Why greedy works here

A greedy algorithm is not always correct. Making change with unusual coin values can fail, because taking the biggest coin can strand you. It works here because the table has a special structure: each value is at most about double the next smaller one, and the subtractive pairs fill exactly the gaps where plain repetition would exceed three. The result matches the place-value table in the earlier section for every integer in range, and the exhaustive test later in this article verifies that claim for all 3999 inputs rather than asking you to trust the argument.

JavaScript implementation

const TABLE = [
  [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],
  [100, "C"], [90, "XC"], [50, "L"], [40, "XL"],
  [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];

function toRoman(n) {
  if (!Number.isInteger(n) || n < 1 || n > 3999) {
    throw new RangeError("Out of range: " + n);
  }
  let out = "";
  for (const [value, symbol] of TABLE) {
    while (n >= value) {
      out += symbol;
      n -= value;
    }
  }
  return out;
}

The guard comes first. Number.isInteger rejects 3.5, NaN and Infinity, which a plain comparison would let through in some cases. Details of JavaScript's number type, including its limits, are documented in the MDN reference for Number. For our range, doubles are exact, so precision is never a concern here.

Python implementation

TABLE = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
         (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
         (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")]

def to_roman(n: int) -> str:
    if not isinstance(n, int) or isinstance(n, bool) or not 1 <= n <= 3999:
        raise ValueError(f"out of range: {n!r}")
    out = []
    for value, symbol in TABLE:
        count, n = divmod(n, value)
        out.append(symbol * count)
    return "".join(out)

Two Python details deserve a note. bool is a subclass of int, so True would otherwise pass as 1, and the explicit check prevents that surprise. And divmod replaces the inner while loop with a single step, which is shorter and does the same work.

Running it

Both implementations were run on this article's sandbox, and they agree. The JavaScript version printed these values for a handful of inputs:

Input Output
4 IV
1994 MCMXCIV
2024 MMXXIV
3999 MMMCMXCIX

Calling toRoman(0), toRoman(4000) and toRoman(3.5) each threw the range error, as designed.

Algorithm 2: Roman to integer

The reverse direction has two classic approaches. Both are linear in the length of the string.

Approach A: scan from right to left

Start at the end of the string with a running total and a record of the largest value seen so far. For each symbol going leftward, if its value is smaller than the largest value already seen, subtract it. Otherwise add it. The idea is that a smaller symbol sitting left of a larger one is exactly the definition of a subtractive pair.

Trace MCMXCIV from the right:

Symbol Value Largest seen Action Total
V 5 0 add 5
I 1 5 subtract 4
C 100 5 add 104
X 10 100 subtract 94
M 1000 100 add 1094
C 100 1000 subtract 994
M 1000 1000 add 1994

The final total is 1994. Note that the comparison is against the largest value seen so far, not just the neighbour to the right. For strictly valid input the two rules agree, but the running-maximum version is a little more robust, and it keeps a sequence like XIX correct.

Approach B: look ahead at the next symbol

Scan from left to right. If the current symbol's value is less than the next one's value, subtract it. Otherwise add it. This is the form you will see most often in tutorials. It needs a bounds check at the final symbol, because there is no next one, and it makes the intent of the subtractive rule very visible.

Approach C: parse the token pairs

A third method uses the same table as the forward direction. At each position, try to match the table entries in order, longest-value first, and consume the first symbol that matches. This is the approach used by many short implementations, including the one behind a number of simple online converters. It is compact, but it has a weakness that the troubleshooting section shows in detail: it does not by itself reject malformed input. It reads whatever it can and quietly ignores the rest.

Why you must validate first

The scanning approaches compute a value from any string of the seven letters. Given IC, approach A returns 99, because 1 is smaller than 100 and gets subtracted. Given IIII, it returns 4. Those results are arithmetically reasonable, but they are not standard numerals. If your goal is parsing only canonical numerals, validation must happen before or alongside the computation. A robust design also gives a second benefit: converting the result back should reproduce the input. That round-trip check is a cheap, strong invariant.

JavaScript implementation

const VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
const STRICT = /^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/;

function fromRoman(s) {
  if (typeof s !== "string" || s === "" || !STRICT.test(s)) {
    throw new SyntaxError("Invalid numeral: " + s);
  }
  let total = 0;
  for (let i = s.length - 1, prev = 0; i >= 0; i--) {
    const v = VALUES[s[i]];
    total += v < prev ? -v : v;
    if (v > prev) prev = v;
  }
  return total;
}

Python implementation

import re

VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
STRICT = re.compile(r"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")

def from_roman(s: str) -> int:
    if not isinstance(s, str) or not s or not STRICT.fullmatch(s):
        raise ValueError(f"invalid numeral: {s!r}")
    total, prev = 0, 0
    for ch in reversed(s):
        v = VALUES[ch]
        total += -v if v < prev else v
        prev = max(prev, v)
    return total

Both versions check for an empty string explicitly, and the reason is in the next section.

Validating with a regular expression

The pattern in the code above is worth reading slowly, because it encodes the place-value table from earlier.

^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$

Reading the pattern

  • ^ and $ anchor the match to the whole string, so MMMMX cannot sneak through as a partial match.
  • M{0,3} accepts zero to three thousands.
  • (CM|CD|D?C{0,3}) is the hundreds group. It is CM for 9, CD for 4, or an optional D followed by zero to three C for the digits 0, 1, 2, 3, 5, 6, 7 and 8.
  • (XC|XL|L?X{0,3}) is the same shape for tens.
  • (IX|IV|V?I{0,3}) is the same shape for ones.

Each of the three groups mirrors one column of the place-value table. That is why the pattern is short and why it rejects IC, VX, IIII and DD: none of them fits any alternative.

The empty-string trap

Every part of the pattern is optional, so the regular expression matches the empty string. In the sandbox run, STRICT.test("") returned true. If you validate with the pattern alone, an empty input reads as a valid numeral worth zero, which is exactly the wrong answer, since no Roman numeral represents zero. The explicit empty check in both implementations closes this hole. An alternative is a lookahead at the start, such as (?=.), which requires at least one character.

Results on invalid input

Running the JavaScript pattern on a set of inputs gave these results.

Input Matches? Reason
IIII false Too many I
IC false Illegal subtraction
VX false V cannot be subtracted
MMMM false More than three M
iv false Lowercase letters (see below)
IIX false Two I before X
(empty) true Needs a separate check

Case and whitespace

The pattern is case-sensitive. Many applications want xiv to work, since lowercase numerals are traditional for preliminary page numbers in books. Normalise before you validate: trim the string, convert it to uppercase, then match. Decide deliberately whether the normalisation belongs in the validator or the caller, and document it. Keeping the validator strict and normalising at the boundary of your system keeps behaviour predictable.

Regular expression performance

The pattern has bounded repetition and no nested quantifiers, so it cannot suffer from catastrophic backtracking. Its run time is proportional to the input length, and a valid numeral is at most 15 characters. If you apply it to untrusted input anyway, cap the length first, since there is no reason to scan a megabyte of text for a number that can never exceed 15 characters. The Python re documentation covers fullmatch, which is a convenient way to avoid anchoring mistakes.

Edge cases and tests

A converter is only as good as its tests. Roman numerals are unusual because the domain is tiny: there are exactly 3999 valid numerals in standard form, so you can test every one.

The exhaustive round trip

let failures = 0;
const seen = new Set();
for (let n = 1; n <= 3999; n++) {
  const roman = toRoman(n);
  if (fromRoman(roman) !== n || seen.has(roman)) failures++;
  seen.add(roman);
}
console.log("failures:", failures, "unique:", seen.size);

Here is what happened when this ran. The JavaScript run reported zero failures and 3999 unique numerals. The Python run, using the equivalent loop over range(1, 4000), also reported zero failures. The check proves three things at once: every integer from 1 to 3999 converts, every result converts back to the original, and no two integers share a spelling. It does not prove that the output matches the historical or conventional spelling, so we also test known pairs by hand.

Known-answer tests

Choose cases that hit each rule.

Integer Roman What it exercises
1 I Lower bound
3 III Repetition
4 IV Subtractive
9 IX Subtractive
14 XIV Mixed
40 XL Tens subtractive
90 XC Tens subtractive
400 CD Hundreds subtractive
900 CM Hundreds subtractive
1994 MCMXCIV Every subtractive pair type
2024 MMXXIV A typical year
3888 MMMDCCCLXXXVIII Longest numeral
3999 MMMCMXCIX Upper bound

Rejection tests

Every invalid input needs a test, not only the happy path.

  • Numbers: 0, -1, 4000, 3.5, NaN, Infinity, and non-numbers such as strings and null.
  • Numerals: the empty string, IIII, IC, VX, IIX, MMMM, VV, iv (if you chose strictness), and strings with spaces.
  • Types: in JavaScript, the string "12" is not an integer, so decide whether to coerce. In Python, True and 1.0 should be rejected or accepted on purpose, not by accident.

Property-based thinking

Beyond examples, state invariants that must always hold. First, fromRoman(toRoman(n)) === n for every valid n. Second, the output of toRoman always matches the strict pattern. Third, toRoman is strictly monotonic in the sense that a larger integer never gives a numeral with a smaller value when parsed. Fourth, no numeral exceeds 15 characters. Because the domain is finite, you can check all of these exhaustively and need no random generator.

Complexity

Both directions are cheap.

Operation Time Space Notes
Integer to Roman (greedy) O(1) for the bounded range O(1) At most 15 output symbols and 13 table rows
Roman to integer (scan) O(n) in the string length O(1) n is at most 15 in standard form
Regex validation O(n) O(1) No nested quantifiers

Strictly speaking, with a fixed range of 1 to 3999 everything is constant time, because the input size is bounded. If you extend to larger values with a vinculum, the output grows with the number of thousands digits, and the greedy algorithm is linear in the output length. In practice the cost is negligible. As a rough local measurement, a loop that performed 400,000 round trips (100 passes over 1 to 3999, each converting to Roman and back with validation) took about 144 milliseconds on the sandbox machine in Node.js. Your hardware will give different numbers, and the figure is only meant to show that performance is not a concern.

Precomputation is also an option. With only 3999 valid numerals, you could build a lookup table once and answer every query with a map access. That trades a few hundred kilobytes of memory for constant-time lookups, and it is rarely worth the trouble beyond a hot loop that converts millions of values.

Sorting Roman numerals

Sorting is where naive code produces confusing output. Roman numerals are strings, and string sorting orders them by character code, not by value.

Sorting the numerals for 3, 14, 9, 40, 4 and 1994 as plain strings gave this order in the sandbox:

III IV IX MCMXCIV XIV XL

That reads as 3, 4, 9, 1994, 14, 40, which is wrong. Sorting with a key that converts to an integer first gave the right order:

III IV IX XIV XL MCMXCIV
numerals.sort((a, b) => fromRoman(a) - fromRoman(b));
sorted(numerals, key=from_roman)

Why lexicographic order fails

Alphabetical order puts I before M before V before X, and has nothing to do with the values 1, 1000, 5 and 10. The two orders can agree by chance on small examples, so a test with only a few items can pass while the code is wrong. Always include a case such as XIV against XL, or MCMXCIV against XIV.

Sorting efficiently and stably

For a large list, convert once, sort, and convert back, rather than calling fromRoman inside the comparator for every comparison. In JavaScript, map each numeral to a pair of value and text, sort the pairs by value, and then extract the text. In SQL databases, store the integer in its own column and render the numeral only for display. The numeral is a presentation of a number, not a good primary key or sort key.

Unicode: Roman numeral characters versus ASCII letters

Unicode contains dedicated characters for Roman numerals in the Number Forms block, which starts at U+2160. They look like ordinary letters, which is exactly the problem.

What is in the block

Range or code point Contents
U+2160 to U+216F Capital numerals from Ⅰ (one) to Ⅿ (one thousand), including Ⅻ (twelve) at U+216B
U+2170 to U+217F The lowercase equivalents
U+2180 to U+2182 Old forms such as ↀ (1000), ↁ (5000), ↂ (10,000)
U+2183 and U+2184 Reversed C and its lowercase form, used in the apostrophus notation
U+2185 to U+2188 Archaic forms for six, fifty, fifty thousand and one hundred thousand

I verified the first and last code points of the main range in the sandbox: Ⅰ is U+2160, Ⅿ is U+216F, ↀ is U+2180 and the hundred-thousand form is U+2188.

Why this matters for validators

The character Ⅳ (U+2163) is a single code point that represents four. It is not the two letters I and V. In a sandbox test, the pattern /^[IVXLCDM]+$/ returned false for it, so a validator written for ASCII letters will reject text copied from a document that substitutes these characters. The same problem hits search: a page containing Ⅻ is not found by a search for XII.

Normalising with NFKC

These characters have compatibility decompositions. Unicode normalisation form NFKC maps each one to the equivalent ASCII letters. In the sandbox, normalising the four characters Ⅰ, Ⅱ, Ⅲ and Ⅳ written side by side, "ⅠⅡⅢⅣ".normalize("NFKC"), produced IIIIIIIV, and Ⅻ became XII. The same applied to ⅩⅠ, which became XI. The normalize method is documented on MDN.

function normalizeRoman(input) {
  return input.normalize("NFKC").trim().toUpperCase();
}

Note the trap in the sandbox output above: concatenating Ⅰ, Ⅱ, Ⅲ and Ⅳ gave IIIIIIIV, which is not a meaningful numeral, because normalisation operates per character and you lose the boundaries. Apply it to one numeral at a time, such as a single table cell or form field.

The archaic and reversed characters in the block, such as ↀ and Ↄ, do not decompose into plain letters, so you cannot rely on NFKC to rescue them. Reject them with a clear message, or map them yourself if you decided to support the extension.

Output: letters or the dedicated characters?

For output, use ASCII letters. They are searchable, copy and paste cleanly, work in every font, and match what screen readers expect. The dedicated characters are mostly for East Asian typography, where a single-width glyph may be wanted, and they are compatibility characters, which the Unicode standard discourages using in new text. They also render inconsistently, since not every font includes the full block.

Where Roman numerals are still used

Roman numerals did not disappear. They survive in contexts where tradition, formality or visual distinction matters more than arithmetic.

Clocks and watches

Dials are the most visible modern use. As discussed above, many use IIII for four. A converter that feeds a clock face should therefore be tested against what the designer wants, not just the standard.

Formal outlines use capital Roman numerals for top-level sections, then capital letters, then Arabic numbers. Statutes, contracts and reports use them for parts or articles. The benefit is a visual distinction between levels. A programmer building a document outline will need numeral rendering and, for parsing pasted outlines, tolerant input handling including trailing periods.

Regnal and ordinal names

Monarchs, popes and some other titles use numerals to distinguish people sharing a name, as in Elizabeth II or a pope named Benedict XVI. Some families use them for generational names as well. These are small values, but they appear in data models for people and organisations, where a name field like Henry VIII may need to be sorted by the numeral, not by text.

Events and franchises

The Super Bowl is named with a Roman numeral for each edition. The numbering convention is a famous example of a modern institution choosing numerals for their ceremonial feel. The same game has also shown the system's friction: for its fiftieth edition, the league used the Arabic 50, reportedly because a lone L was judged a weak logo. The numeral system's brand value is real, but so is the readability cost. Olympic Games editions and film sequels follow similar traditions.

Film and television credits traditionally show the year in Roman numerals, which makes the date harder to read at a glance. Some say this was intended to make a film look less dated to casual viewers, though that is a commonly repeated explanation and not a documented policy. Whatever the reason, a developer building a media database may need to parse strings like MCMXCIV from scanned credits.

Books, music and architecture

Preliminary pages of books, such as prefaces, often use lowercase Roman numerals, so the main text can start at page 1. Music theory uses Roman numerals for chord analysis, where case encodes major or minor quality, a convention that is unrelated to numeric value. Building cornerstones and monuments use them for dates. In each case the numerals are labels, not quantities for arithmetic.

Software

In code, Roman numerals appear in list styling (CSS supports lower-roman and upper-roman for ordered lists), in word processors, in table-of-contents generators and in educational apps. Accessibility deserves a moment here. A screen reader announces an ordered list marker using the list's counter, and a numeral typed as text is read as letters in some cases, so prefer semantic list markup over hand-typed numerals, and see our HTML formatting and semantic markup guide for how lists should be structured. The styling side is covered in our CSS formatting and architecture guide.

Common mistakes and pitfalls

These are the errors that show up repeatedly in tutorials, interviews and production code.

  • Treating subtraction as arbitrary. Allowing any smaller-before-larger pair makes IC, IL and XM valid. Only six pairs are legal in standard form.
  • Forgetting the repetition limits. Allowing VV or IIII breaks uniqueness, and with it the round-trip property.
  • Accepting zero. There is no Roman zero. A converter that returns an empty string for 0 hides a bug in the caller.
  • Skipping the range check. Input above 3999 produces MMMM and beyond, which looks plausible and is wrong.
  • Matching the empty string. The standard regular expression accepts it unless you add a guard.
  • Validating after you parse. A lenient parser accepts garbage, and then the validator never sees the original text. Validate first.
  • Sorting as strings. Alphabetical order is not numeric order.
  • Ignoring case and whitespace. Real user input contains lowercase letters, spaces and trailing punctuation.
  • Confusing Unicode numerals with letters. A pasted Ⅳ is not IV until you normalise it.
  • Using floating-point input. 3.0 and 3.5 behave differently in different languages. Decide what counts as an integer.
  • Assuming the output is canonical for historical text. Sources use IIII, VIIII and other variants, and a parser for real documents may need a tolerant mode.
  • Treating the greedy loop as only for conversion. The same table is the right basis for validation tests, because a canonical numeral is exactly what the greedy loop produces.

Step-by-step: convert with the DevUtilX Roman Numeral Converter

For a quick conversion, with no code or setup, use the DevUtilX Roman Numeral Converter. It runs entirely in your browser.

  1. Open the Roman Numeral Converter.
  2. To convert a number, type it into the Arabic to Roman box. The Roman numeral appears in the output field below it as you type.
  3. To go the other way, type a numeral such as MCMXCIV into the Roman to Arabic box. The integer appears in the output field.
  4. Use the Copy button beside either output to put the result on your clipboard.
  5. Compare the result with your own code. The tool is handy as a reference when you are debugging an implementation.

Your input is processed locally and is not uploaded, so it is safe to use with draft documents or unpublished material. A few practical notes will save you confusion. The tool uses the same value table as the greedy algorithm in this article, so a valid integer in the range 1 to 3999 gives the standard spelling. The reverse direction reads the text by passing once through the table, which means it is forgiving. When I ran the same reading logic on a set of inputs in the sandbox, IIII gave 4, IC gave 1, VX gave 5 and IIX gave 2, and text with no numeral letters gave 0. Treat the result of a malformed numeral as meaningless and check your input against the rules above before relying on the output. For numbers above 3999, expect a non-standard run of M characters, since standard form cannot represent them.

If you work with other bases, the Roman numeral tool pairs naturally with converters for binary, octal and hexadecimal. The same idea of one number with several spellings runs through all of them, and we compare them below.

Using the module from the command line and build scripts

Sometimes you need numerals in a build or documentation pipeline, such as generating numbered preface pages or a table of contents. The JavaScript module from this article can be dropped into a Node script unchanged. Export both functions and the pattern.

module.exports = { toRoman, fromRoman, STRICT };

Then a one-line command prints the numeral for a year:

node -e "const {toRoman}=require('./roman'); console.log(toRoman(2024))"

That prints MMXXIV. In Python, the equivalent is a call to to_roman(2024) from a short script or a REPL session. For code in other languages, the same thirteen-row table translates directly: a loop with integer division in C, Java or Go, a match over the table in Rust, or a Data.List fold in Haskell. The algorithm is the same everywhere, and the table is the only data you need.

Performance, security and accessibility considerations

Roman numerals are a small domain, but the usual engineering concerns still apply in miniature.

Performance

As the complexity section showed, the work per conversion is tiny. The practical performance advice is to avoid pathological patterns around the converter: do not build the numeral by repeated string concatenation inside a hot loop for huge counts, do not call the parser inside a sort comparator for large lists, and do not compile the regular expression on every call. In JavaScript a regular expression literal defined at module level is compiled once. In Python, re.compile at module level does the same.

Security

Input validation is the whole security story. A numeral field should reject anything outside the seven letters, cap the input length, and never pass raw text into a template or query without escaping. A pattern with no nested quantifiers cannot be used for a denial-of-service attack, but a naive hand-written alternative sometimes can, which is another reason to use the bounded pattern in this article. If you accept numbers from a form, bound them before looping: a greedy loop given 10**12 would build a string of a billion M characters and exhaust memory. The range guard in toRoman prevents that, and it is a good reminder that range checks are a security control, not only a correctness one.

The tool on DevUtilX does its work in your browser, so nothing you type is sent to a server. That matters less for numerals than for, say, tokens or customer data, but it is a good default for any converter.

Accessibility

Text in Roman numerals can be hard to read for many people, including those with cognitive differences, those using translation software, and those who are simply unfamiliar with the system. Screen readers may read XIV as three letters rather than the number fourteen. A few measures help.

  • Provide the Arabic form nearby when the information matters, such as a date in a legal notice.
  • Use semantic markup. Ordered lists with a numbering style are announced correctly, and typed numerals are not.
  • Use ASCII letters, not the dedicated Unicode characters, which some assistive technology handles poorly.
  • Use an accessible name such as an aria-label carrying the Arabic value when a numeral appears as an isolated label, such as on a clock widget. Do this sparingly and test with a real screen reader.
  • Do not rely on the numeral alone to convey ordering in a UI. Make the sequence clear in structure too.

Troubleshooting guide

When conversions misbehave, work from the symptom to the cause.

Symptom: the converter returns an empty string for 0

Cause. The greedy loop has nothing to emit for zero, so it falls through with an empty result.
Fix. Guard the lower bound. Reject 0 and negative numbers with a range error, since there is no Roman zero.

Symptom: 4000 or 5000 produces a long run of M characters

Cause. The table contains no symbol above 1000, so the greedy loop keeps emitting M.
Fix. Reject values above 3999, or implement an explicit extension such as the vinculum and say so in your documentation.

Symptom: IC or VX is accepted and gives a number

Cause. The parser sums or subtracts without checking that the pair is legal. Single-pass table readers and left-to-right scanners both behave this way.
Fix. Validate with the strict regular expression first. Alternatively, convert the result back and compare it with the input. If toRoman(fromRoman(s)) !== s, the input was not canonical.

Symptom: the empty string validates as a numeral

Cause. Every group in the regular expression is optional.
Fix. Add an explicit non-empty check, or begin the pattern with a lookahead such as (?=.).

Symptom: lowercase input is rejected

Cause. The pattern only contains capital letters.
Fix. Normalise at the boundary: trim, then convert to uppercase, then validate. Do not make the pattern case-insensitive unless you want that policy everywhere.

Symptom: a numeral pasted from a document is rejected, though it looks right

Cause. The text contains dedicated Unicode numeral characters from the U+2160 block, or invisible characters such as non-breaking spaces and zero-width marks.
Fix. Apply normalize("NFKC"), trim whitespace, and then validate. Log the code points of rejected input when debugging, for example with [...s].map(c => c.codePointAt(0).toString(16)).

Symptom: a list sorts as III, IV, IX, MCMXCIV, XIV, XL

Cause. Strings are sorted by character code.
Fix. Sort by the integer value, using a key function. Confirm the fix with the pair XIV and XL.

Symptom: a clock face or historical text fails validation

Cause. The source uses IIII, VIIII or another additive variant, which strict validation rejects.
Fix. Offer a lenient mode that accepts additive variants, and normalise to the canonical form on output. Keep the lenient and strict behaviours separate, and test both.

Symptom: an outline heading IV. fails

Cause. The trailing period is part of the string.
Fix. Strip the trailing punctuation before validating. The punctuation belongs to the document format, not to the numeral.

Symptom: the Python function accepts True

Cause. In Python, bool is a subclass of int.
Fix. Exclude booleans explicitly, as the implementation above does.

Best practices and checklist

  • Pick one definition of valid and document it. Strict standard form is the best default.
  • Use the thirteen-row table for output. It is short, fast and has no special cases.
  • Validate before parsing. A strict regular expression, plus a non-empty check, is enough.
  • Verify by round trip. The domain has only 3999 members, so test them all.
  • Reject out-of-range input loudly. State the supported range in the error message.
  • Normalise at the boundary. Trim, upper-case and apply NFKC before validation.
  • Output ASCII letters. They are searchable, accessible and font-proof.
  • Sort by value. Never rely on string order.
  • Keep integers as the source of truth. Store the number, and render the numeral for display.
  • Test the rejections, not only the happy path.
  • Cap input length before you apply any pattern to user input.
  • Offer a lenient mode only where you need one, such as historical text, and keep it separate.

Before shipping a feature that touches Roman numerals, run through this list:

  1. Does the code reject 0, negative numbers, non-integers and values above 3999?
  2. Does the validator reject IIII, IC, VX, IIX, MMMM and the empty string?
  3. Is there an exhaustive round-trip test for 1 to 3999?
  4. Does sorting use numeric values?
  5. Is input normalised for case, whitespace and Unicode forms?
  6. Is the output in ASCII letters, with the Arabic value available where accessibility matters?
  7. Is the input length capped?
  8. Is the policy for historical variants such as IIII written down?

Comparison: Roman numerals and other numeral systems

Seeing Roman numerals next to other systems explains both their quirks and their decline. The same year, 2024, can be written in many ways.

System 2024 written as Positional? Has zero? Notes
Hindu-Arabic decimal 2024 Yes Yes The global default for arithmetic
Roman numerals MMXXIV No No Additive and subtractive, letters
Binary 11111101000 Yes Yes Base 2, used inside computers
Hexadecimal 7E8 Yes Yes Base 16, compact for binary data
Tally marks Marks in groups of five No No Unary, easy but long

The binary and hexadecimal rows follow from the arithmetic: 2024 equals 1024 + 512 + 256 + 128 + 64 + 32 + 8, which gives 11111101000, and 7 × 256 + 14 × 16 + 8 gives 2024, so the hexadecimal form is 7E8.

Why positional systems won

In a positional system, adding two numbers is a column-by-column procedure that a child can learn, and it generalises to multiplication and division with paper algorithms. In Roman numerals, there are no columns. Adding XLVII and XXXVIII means translating the symbols into quantities, combining them and re-encoding the result. Multiplication is worse. A numeral system is a technology, and positional notation with a zero is a better calculating technology.

Roman numerals trade that power for other qualities. They are compact for small numbers, they are hard to alter by adding a stroke (though that argument is mostly folklore), and they carry a sense of formality. They suit labels, which is why they survive on cornerstones and clock dials.

Comparing implementation strategies

If you must support Roman numerals in a product, there are several ways to do it, each with trade-offs.

Approach Strengths Weaknesses
Greedy table plus strict regex Small, fast, easy to test Standard form only
Place-value lookup tables Very explicit, mirrors the rules More code and data
Precomputed map of 3999 entries Constant-time lookup Memory and setup cost, rarely needed
Third-party library Less code to write Dependency, unknown edge-case policy
Online tool such as DevUtilX No code, instant checks Manual, not for automation
Lenient parser with round-trip check Accepts historical variants More complex, must define canonical output

For most applications the first row is the right answer. Reach for the others when you have a concrete reason, such as historical text or a performance measurement that justifies a lookup table.

Roman numerals and number systems you already know

Developers meet other representations of integers every day, and each has a counterpart to the pitfalls above. Base conversion has the same structure: a number is the thing, and a numeral is its spelling. Our broader reading on numeral systems is a good place to put Roman numerals in context.

Three parallels are worth drawing. First, normalisation: just as 0x1F, 1F and 31 can all denote the same integer, XIV, xiv and Ⅻ-style characters can all denote one value, so you normalise before comparing. Second, canonical form: a number has many possible spellings, such as 007 and 7, and a system that needs uniqueness picks one. Standard Roman form is exactly that choice. Third, round trip testing: whenever a conversion is invertible, the cheapest test is to apply it and its inverse over the whole domain.

If your data pipeline also handles formatted files such as JSON, our guide to minifying JSON and understanding payload size shows another case where two textual forms carry the same data, and our JavaScript code formatting best practices explains how to keep helper modules like the one in this article readable.

A note on design: making the converter testable

Small utilities often become big liabilities because they are written once and never tested. A few structural choices keep a Roman numeral module healthy.

Keep pure functions separate from input handling

toRoman and fromRoman should take plain values and return plain values or throw. Code that reads form fields, trims text, handles locale and displays errors should live outside them. This separation lets you test the algorithms exhaustively and test the interface with a handful of cases.

Use specific errors

A RangeError for numbers outside 1 to 3999 and a SyntaxError or ValueError for malformed numerals let callers react differently. A form can show "enter a number between 1 and 3999" for the first and "that is not a standard Roman numeral" for the second. A single generic error forces users to guess.

Make the policy visible

Put the choices in the module's documentation: strict standard form, range 1 to 3999, uppercase only, no Unicode numeral characters unless normalised. When a teammate later reports that IIII is rejected, you can point to a decision, not defend an accident.

Treat the table as the single source of truth

Both directions can be driven by the same table. If you ever extend the system, for instance to add a vinculum range, you change one data structure and the tests tell you what broke. Duplicating the values in a second place, as in separate lookup tables for each direction, invites divergence.

FAQ

What is the largest number you can write in standard Roman numerals?
3999, written MMMCMXCIX. Standard form allows M at most three times and has no larger symbol.

Is there a Roman numeral for zero?
No. The system has no zero symbol, and a correct converter should reject 0. Some medieval writers used a Latin word for nothing, or the letter N, in tables, but that was never part of the numeral system.

Why is 4 sometimes written IIII?
It is an older additive spelling and a long-standing convention on many clock faces. Historians offer several explanations for the clock tradition, including visual balance and avoiding confusion with VI. Standard form uses IV.

Why is 99 written XCIX and not IC?
Because only I, X and C can be subtracted, and each only from the next two symbols above it. I may precede V and X, not C. Treat 99 as 90 plus 9, written XC then IX.

How do I write numbers over 3999?
A bar over a numeral, the vinculum, multiplies it by 1000, so a barred IV is 4000. The convention is less standardised than the basic form, and plain text has no overline, so most software stops at 3999.

Can I use lowercase Roman numerals?
Yes, they are conventional for things like preface page numbers. Value is the same as the uppercase form. In code, normalise to uppercase before validating.

What is the difference between the letter X and the Unicode character Ⅹ?
The first is ASCII and Latin, U+0058. The second is a Roman numeral character in the Number Forms block, U+2169. They look alike but are different code points. Use NFKC normalisation to convert the dedicated characters to letters before validating.

How do I check whether a string is a valid Roman numeral?
Use the anchored pattern ^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$ and also reject the empty string, since the pattern matches it.

Does greedy conversion always work?
It works for the thirteen-entry table that includes the subtractive pairs, and the 1 to 3999 round trip in this article confirms that every integer converts and comes back unchanged. Greedy algorithms are not correct for every problem, so the table's structure is what makes it safe here.

Why do sorted Roman numerals look wrong?
String sorting compares characters, not values. Convert to integers and sort by those, or store the integer and render the numeral only when displaying it.

Are Roman numerals still worth learning?
For everyday arithmetic, no. For reading clocks, outlines, monarch names, film credits and building dates, yes, and they are a useful small problem for practising parsing, validation and exhaustive testing.

Is it safe to use an online converter?
Only if it processes input locally. The DevUtilX tool runs in your browser and does not upload what you type, but as a rule you should avoid pasting confidential data into any tool.

Can the same numeral have two valid spellings?
In standard form, no. Each integer from 1 to 3999 has exactly one spelling, which is why round-trip testing works. Historical sources are different, since IIII and IV both appear.

Conclusion

Roman numerals teach a lesson that applies far beyond Rome: a number and its spelling are different things, and software that converts between them needs a clear definition of the allowed spellings. Fix the standard form, drive output from a thirteen-row table, validate input with a strict pattern and a non-empty check, normalise case and Unicode at the boundary, sort by value, and test all 3999 members of the domain. For a quick conversion without writing anything, try the Roman Numeral Converter. To keep going, read our guides on JSON formatting, where the same normalise-then-validate approach applies, and JavaScript code formatting best practices for keeping utility modules like this one clean.

Further reading

Try the tools

Further reading

Related articles