Skip to content

Numbers become text with format, to_s(16) and %

Level: 201 · anyone printing a price, a hex dump or a table

One line: format speaks C's mini-language — with ..-prefixed two's-complement negatives and no thousands flag — to_s(base) and to_i(base) go between bases, Integer() parses strictly where to_i never fails, and Float#to_s prints the shortest string that reads back as the same Float; Python's f-strings add a , flag, keep the minus sign on a negative, and switch to exponent form one digit later.

format(spec, *args) — also sprintf, and spec % args on a String — is where Ruby turns a number into text with a width, a precision or a base: %.2f, %08.3f, %e, %x, %b, %o, %+d, %-5d, and %#x for a prefix. Integer#to_s(base) writes any base from 2 to 36 without a prefix, and String#to_i(base), hex and oct read it back — never failing, so junk becomes 0 — while Integer(str) and Float(str) raise on anything that is not a number and read 0x, 0b, 0o and a bare leading 0 as prefixes. A Float prints itself as the shortest decimal that reads back as the same Float — 0.30000000000000004, 1.0e+20 — and formatting is how you choose the digits instead. Two rounding facts come with all this: format("%.2f", 2.675) is "2.68" where Python prints 2.67, and Integer#round(-2) rounds a half away from zero, so 1250.round(-2) is 1300.

Verified output of number_formatting_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. format("%.2f", 3.14159)                    -> "3.14"                     fixed decimals; "%.2f" % 3.14159 is the String#% spelling
 2. format("%08.3f", 3.14159)                  -> "0003.142"                 width 8, zero-filled, 3 decimals
 3. format("%e", 123456.789)                   -> "1.234568e+05"             scientific; %.3e of 0.000123456 is "1.235e-04", %g picks a form: "1.23457e+06"
 4. format("%x %b %o", 255, 255, 255)          -> "ff 11111111 377"          bases without a prefix; %#x %#b %#o give "0xff 0b11111111 0377"
 5. format("%+d|%05d|%-5d|", 5, 42, 42)        -> "+5|00042|42   |"          a sign, zero fill, left alignment
 6. format("%08b", -5)                         -> "..111011"                 a negative in a base is two's complement behind a .. prefix; %x of -255 is "..f01"
 7. format("%.2f", 2.675)                      -> "2.68"                     not 2.67: 2.665 gives 2.66 and 2.685 gives 2.68, half to even on the decimal digits
 8. 1234567.to_s.gsub(/\B(?=(\d{3})+\z)/, ",") -> "1,234,567"                no thousands flag in format, so an idiom; %,d is ArgumentError: malformed format string - %,
 9. 255.to_s(16), 255.to_s(2), 255.to_s(36)    -> "ff", "11111111", "73"     to_s takes a base from 2 to 36
10. "ff".hex, "ff".to_i(16), "0x1f".to_i(0)    -> 255, 255, 31               back from a base; base 0 reads the prefix; "zz".hex is 0, because to_i never fails
11. Integer("0x1f", 16), Integer("0b101"), Integer("1_000") -> 31, 5, 1000                Integer() reads prefixes and underscores, and raises on junk
12. Integer("08")                              -> ArgumentError: invalid value for Integer(): "08" a leading 0 means octal, where 8 is not a digit; "08".to_i is 8
13. (0.1 + 0.2).to_s, 1e20.to_s                -> "0.30000000000000004", "1.0e+20" to_s is the shortest string that reads back as the same Float; 100.0.to_s is "100.0", never without the .0
14. 999999999999999.9.to_s, 1e15.to_s          -> "999999999999999.9", "1.0e+15" exponent form once the integer part has 16 digits; and below 0.0001: 1e-4.to_s is "0.0001", 1e-5.to_s is "1.0e-05"
15. 1234.5678.round(2), .floor(2), .round(-2)  -> 1234.57, 1234.56, 1200     round(-2) returns an Integer; 1250.round(-2) is 1300, half away from zero
16. 3.1.round(2).to_s, format("%.2f", 3.1)     -> "3.1", "3.10"              round drops trailing zeros, format keeps them: money wants format

Reading the output

Rows 1–7 are format. The directives are C's: a precision after the dot, a width before it, 0 to fill with zeros, + for an explicit sign, - to left-align, e and g for the scientific and the shorter form, x, b and o for bases, and # for a prefix — where Ruby's octal prefix is a bare 0, not Python's 0o. Row 6 is Ruby's own convention for a negative number in a base: %08b of -5 is ..111011, the two's-complement bit pattern with .. standing for the infinite run of ones to its left, and %x of -255 is ..f01. Row 7 is a rounding surprise: the Float 2.675 is stored as a value just below 2.675, and rounding that exact value — what Python does — gives 2.67, but Ruby's format gives 2.68; 2.665 gives 2.66 and 2.685 gives 2.68, which is the pattern of rounding the decimal digits 2.675 half to even, the shortest form rather than the exact value (Not machine-checked here.). Row 8 is the missing feature: format has no thousands separator, so Ruby code inserts commas with a regex — \B(?=(\d{3})+\z) marks every position that is followed by a multiple of three digits — and %,d is a malformed format string.

Rows 9–12 are bases and parsing. to_s(16), to_s(2) and to_s(36) write a number in any base; hex, to_i(16) and to_i(0) — base 0 reads the prefix — bring it back. The to_i family never raises: "zz".hex is 0, and "08".to_i is 8. Integer() is the strict parser: it understands prefixes and underscores, and it raises on junk — including "08", because a leading 0 makes it read octal, where 8 is not a digit. Rows 13 and 14 are Float#to_s: the shortest round-trip form, always with a decimal point or an exponent, switching to 1.0e+15 form once the integer part reaches 16 digits and to 1.0e-05 form below 0.0001. Rows 15 and 16 are rounding against formatting: round(2) and floor(2) return Floats, round(-2) returns an Integer and rounds a half away from zero (1250 to 1300), and round drops trailing zeros — 3.1.round(2) prints as 3.1 — where format("%.2f", 3.1) keeps them, which is why money is formatted, not rounded.

Compared with Python

The mini-language is the same one — format(x, ".2f"), f"{x:08.3f}", % on a string — with a few additions and two different conventions. Python has a thousands flag built in, f"{n:,}" and f"{n:_}", which is row 8's regex in one character. hex, bin and oct always carry their prefix (format(255, "x") is the bare form), and #o gives 0o377. A negative number keeps its minus sign in every base — f"{-5:08b}" is -0000101 — where Ruby shows two's complement. int(s, base) is strict like Integer(): junk raises, a prefix needs base 0 or the matching base, and int("08") is simply 8, because a leading zero means nothing in base 10 (int("017", 0) raises instead). Row 7 is the rounding difference: format(2.675, ".2f") is 2.67, from the exact binary value. Row 14 is the threshold: str(1e15) is positional and str(1e16) is the first exponent form, one digit later than Ruby, and the exponent form has no .0 (1e+20). Row 15 is round(1250, -2) giving 1200 — banker's rounding again — and round(x, -2) on a float staying a float.

Verified output of number_formatting_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. format(3.14159, '.2f')                     -> '3.14'                     fixed decimals; f'{3.14159:.2f}' and '%.2f' % 3.14159 are the other spellings
 2. f'{3.14159:08.3f}'                         -> '0003.142'                 width 8, zero-filled, 3 decimals
 3. f'{123456.789:e}'                          -> '1.234568e+05'             scientific; f'{0.000123456:.3e}' is '1.235e-04', g picks a form: '1.23457e+06'
 4. f'{255:x} {255:b} {255:o}'                 -> 'ff 11111111 377'          bases without a prefix; #x #b #o give '0xff 0b11111111 0o377'
 5. f'{5:+d}|{42:05d}|{42:<5d}|'               -> '+5|00042|42   |'          a sign, zero fill, left alignment
 6. f'{-5:08b}'                                -> '-0000101'                 a negative keeps its minus sign, no two's complement; f'{-255:x}' is '-ff'
 7. format(2.675, '.2f')                       -> '2.67'                     the exact binary value is rounded: 2.665 gives 2.67 and 2.685 gives 2.69
 8. f'{1234567:,}'                             -> '1,234,567'                a thousands flag is built in; f'{1234567:_}' is '1_234_567', f'{1234567.891:,.2f}' is '1,234,567.89'
 9. hex(255), bin(255), format(255, 'x')       -> '0xff', '0b11111111', 'ff' hex/bin/oct carry a prefix; no to_s(36), but int('73', 36) reads it: 255
10. int('ff', 16), int('0x1f', 16), int('0x1f', 0) -> 255, 31, 31                back from a base; base 0 reads the prefix; int('zz', 16) is ValueError: int() always raises on junk
11. int('0b101', 0), int('1_000'), int('017', 8) -> 5, 1000, 15                a prefix needs base 0 or the matching base; underscores are fine
12. int('08')                                  -> 8                          a leading 0 is just a digit in base 10; int('017', 0) is ValueError
13. str(0.1 + 0.2), str(1e20)                  -> '0.30000000000000004', '1e+20' str is the shortest string that reads back as the same float; no .0 in the exponent form
14. str(999999999999999.9), str(1e15)          -> '999999999999999.9', '1000000000000000.0' positional up to 16 integer digits; exponent form from 17: str(1e16) is '1e+16'; small: str(1e-5) is '1e-05'
15. round(1234.5678, 2), round(1234.5678, -2)  -> 1234.57, 1200.0            round(x, -2) on a float stays a float; round(1250, -2) is 1200, half to even
16. str(round(3.1, 2)), f'{3.1:.2f}'           -> '3.1', '3.10'              round drops trailing zeros, format keeps them: money wants format, or Decimal

The Python library's The format mini-language ↗ works through the spec character by character, and bin() is not the bits ↗ is the page on bin(-5) printing a sign rather than a bit pattern — row 6 is Ruby's answer to the same question.

Try it

  1. Print a price table — item names left-aligned, amounts right-aligned in a 14-character column with thousands separators and two decimals — for laptop 1299.99, mouse 24.5, desk 1234567.891 and refund -1234.5, then a total line. format("%.2f", x) gives the decimals; one gsub gives the commas.
  2. Write to_base(n, base) that builds what n.to_s(base) returns from n.abs.digits(base) and the digit characters 0-9a-z, and from_base(s, base) that reverses it without calling to_i; check both against to_s and to_i for 255, 1_000_000 and 2 ** 70 in bases 2, 16 and 36.
Solution to 1

number_formatting_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a price table — names left-aligned, amounts right-aligned with
# thousands separators and two decimals — from format and one gsub.

def money(x) = format("%.2f", x).gsub(/\B(?=(\d{3})+\.)/, ",")

items = { "laptop" => 1299.99, "mouse" => 24.5, "desk" => 1234567.891, "refund" => -1234.5 }
items.each { |name, price| puts format("%-8s %14s", name, money(price)) }
puts format("%-8s %14s", "total", money(items.values.sum))

Verified output of number_formatting_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

laptop         1,299.99
mouse             24.50
desk       1,234,567.89
refund        -1,234.50
total      1,234,657.88

See also