Skip to content

Which base did you mean?

Level: 201 · for anyone with a terminal

One line: A run of digits does not carry its base, so something always supplies one — and outside source code that something is rarely visible: 010 is eight to xxd -s, ten to head -c, eight in [[ ]] and ten in [ ], and a refusal to Python's ipaddress.

Every explanation of number systems covers the first half of this: what a base is, and how to convert. That half is genuinely easy, and this library has already spent a chapter on it — Counting in hexadecimal is the odometer, Hex is a shorthand is why four bits get one digit. The half that is left out is the one that costs people afternoons: once you have a value in mind, how do you tell a program which base you typed it in — and what does it assume when you do not?

The easy half, said once, for any base

A written number is a row of columns, and each column is worth base times the one on its right. That is the whole definition, and it is the same sentence in every base:

written columns sum value
11111111 base 2 128 64 32 16 8 4 2 1 128+64+32+16+8+4+2+1 255
377 base 8 64 8 1 3×64 + 7×8 + 7×1 255
255 base 10 100 10 1 2×100 + 5×10 + 5×1 255
FF base 16 16 1 15×16 + 15×1 255

One value, four spellings, and the arithmetic is the arithmetic you already do. Two things follow that the rest of this page is about. The value has no base — 255 is not "a decimal number", it is a quantity, and base 10 is one of the four ways above to write it down. And the spelling does not say which11111111, 377, 255 and FF are each a legal spelling in more than one base, so a reader has to be told, and 10 alone is two, eight, ten or sixteen depending on who is reading.

A footnote on the notation, because it is the one fragile thing here. The usual way to write this out is with exponents — 2×10² + 4×10¹ + 6 — and superscripts are typography rather than text. They are lost by plain-text copies, by some PDF extractions, by screenshots fed through OCR, and by a stylesheet that fails to load, at which point the line arrives as 2*102 + 4*10 + 6 = 246 and cannot be learned from at all: 102 reads as one hundred and two. The table above writes the column values out instead, which no rendering can flatten. It is a small thing, and it is the difference between a formula a beginner can check and a formula they have to take on faith.

The half that is left out: four ways to say the base, and a fifth that says nothing

In source code the question is settled by the grammar, and every base but one has a prefix — that is Writing the literal, including the sting that decimal's "prefix" is nothing at all and C's octal prefix is a single 0. Outside source code there is no compiler. A number typed into a search box, a flag, a config value or a form has to carry its own base, and four notations for that grew up independently:

shape looks like where you meet it
prefix 0x41 · 0o101 · 0b1000001 · 0101 C, Python, Rust, Go, printf '%d', strtol with base 0
suffix 41h · 0FFh · 1010b · 377q assemblers and calculators, and hex editors — 010 Editor takes FFh, 3f,h, d2,x, 377,o, 0101,b
base in front, as a number 16#FF · 16#FF# · 16rFF · #xFF $(( )) in bash, and PostScript and Erlang; Ada closes the literal with a second #; Smalltalk uses r; Lisp tags the base with a letter
quoted, with a tag X'41' · &HFF · $FF the SQL and COBOL binary-string literals, Visual Basic, Motorola and Pascal assembler
nothing 41 a text field, a command-line argument, a JSON value, a spreadsheet cell

The first four are notation, and notation is easy: you can look it up, and a wrong guess is usually a syntax error rather than a wrong number. The fifth row is the whole problem, and it has two failure modes worth naming separately. Sometimes the field has a default — decimal, usually, but not always, and the default can be changed by a control that is not part of the text you typed. And sometimes the field has a fixed base with no notation for overriding it, chmod being the cleanest example: its mode is octal, always, and there is no way to hand it a decimal 755.

010, in one shell, twice

The place to see all of this is a shell, because it contains both a field that asks you to name the base and several that quietly pick one — and two builtins of the same shell disagree about the same four characters.

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

1. THE SHELL CAN BE TOLD, AND IT HAS TWO SPELLINGS FOR SAYING SO
   base#digits -- you name the base in front of a #, in any base from 2 to 64
   $(( 2#11111111 ))  = 255
   $(( 8#377 ))       = 255
   $(( 10#255 ))      = 255
   $(( 16#FF ))       = 255
   and the C prefixes it inherits -- both of them, because there is no 0b here
   $(( 0377 ))        = 255
   $(( 0xFF ))        = 255
   $(( 255 ))         = 255
   $(( 0b11111111 )) is an error: bash took 0x and the leading zero from C
   and stopped there, which is the set C11 itself had. Binary is 2#11111111.
   Seven spellings, one number. The four with a # say the base out loud. The
   three below them do not: 0377 is octal because of one character you can
   miss, and 255 is decimal because nothing said otherwise.

2. SO WHAT IS 010? ONE SHELL, TWO BUILTINS, TWO ANSWERS
   [ 010 -eq 10 ]       true    [[ 010 -eq 10 ]]     false
   [ 010 -eq 8 ]        false   [[ 010 -eq 8 ]]      true
   test(1) reads its operands in base 10. [[ ]] puts them through $(( )),
   where a leading zero is octal. Both are builtins of the same shell, both
   are spelled -eq, and they disagree about the characters between them.

$ printf '%d\n' 010 0x41 255   # printf takes the C prefixes: 8, 65, 255
8
65
255

$ printf '%s\n' 010 9 | sort -n | tr '\n' ' ' | sed 's/ $//'   # sort -n: base 10, so 9 is first
9 010
   And $(( 09 )) is an ERROR -- "value too great for base" -- because the
   leading zero already said octal and 9 is not an octal digit. That is the
   rule speaking up, which it does only when the digits happen to give it away.

3. THE OFFSET YOU TYPE IS NOT IN THE BASE THE DUMP PRINTS

$ xxd -g 4 off.bin
00000000: 30313233 34353637 38396162 63646566  0123456789abcdef
00000010: 6768696a                             ghij
   The offset column is HEX. The second row begins at 00000010, which is 16.

   you type     xxd -s       od -j
   10           61626364     abcd
   010          38396162     89ab
   0x10         6768696a     ghij
   Row 1 is the everyday mistake: you read 00000010 off the dump, typed
   -s 10, and landed six bytes early. Row 2 is the one nobody expects --
   both tools read a leading zero as octal, so 010 seeks to 8, not to 10.
   Row 3 is the spelling that says what it means, and both tools take it.

   head -c is a LENGTH rather than a seek, and reads the same three again:
   head -c 10     10 bytes
   head -c 010    10 bytes
   head -c 0x10   refused
   Ten, ten, and a refusal: this one is base 10 and takes no prefix at all.

4. ONE FIELD WITH NO SPELLING, AND ONE WITH NO CHOICE
   chmod 755      -rwxr-xr-x
   chmod 0755     -rwxr-xr-x
   chmod 888      refused
   chmod's mode is octal always. The leading zero changes nothing, there is
   no way to hand it a decimal 755, and the refusal of 888 is the tell: an
   8 cannot be an octal digit, so the field never had another reading.

$ printf 'A' | od | tidy   # and od's own default OUTPUT base is octal, two bytes at a time
0000000 000101
0000001
   000101 is one byte: 0101 octal, 0x41, 'A'. The oldest byte tool on the
   machine answers in base 8 until you ask it for something else.

Three findings in that, in rising order of how much they cost.

$(( )) will be told, and has two ways of telling it. 16#FF names the base explicitly and works for any base from 2 to 64; 0xFF and 0377 are C's prefixes, inherited as they stand. Only 0x and the leading zero came across — there is no 0b in bash's arithmetic, which is the set C11 itself had.

[ ] and [[ ]] disagree, and both are right. test's operands are read in base 10, so [ 010 -eq 10 ] is true. [[ ]] evaluates its operands arithmetically, so [[ 010 -eq 8 ]] is true. Same shell, same -eq, two numbers, no warning. A zero-padded field out of a CSV or a date — 08, 09, 010 — is therefore a different value depending on which bracket a script happens to use, and $(( 09 )) is the only one of the family that speaks up: it is a syntax error, because the leading zero said octal and 9 is not an octal digit. That error is the friendliest thing in this section, and it only appears when the digits happen to give the game away. 08 and 09 fail loudly; 010 and 011 fail silently.

The offset you type is not in the base the dump prints. This is the one that wastes real time. A hex dump labels its rows in hex — the second row of xxd output starts at 00000010, which is byte 16 — but xxd -s and od -j read their argument with C's rules, so -s 10 seeks to byte 10, six bytes early, and -s 010 seeks to byte 8. The fix is one prefix: read 00000010 off the dump and type -s 0x10. And the same three spellings mean something different again one flag along, because head -c takes a length in base 10 and no prefix at all, so head -c 010 reads ten bytes and head -c 0x10 is refused outright.

dd is the one that will not agree with itself across platforms

xxd -s, od -j and head -c each give the same answer on macOS and on Ubuntu. dd does not, in both directions, which is why it is in a fence here rather than in this page's answer key.

Measured 2026-09-12, one 20-byte file holding "0123456789abcdefghij"

  macOS 26, BSD dd               dd skip=010 -> 89ab      dd skip=0x10 -> ghij
  Ubuntu 24.04, coreutils 9.4    dd skip=010 -> abcd      dd skip=0x10 -> 0123
                                                          + dd: warning: '0x' is a
                                                            zero multiplier; use '00x'

BSD dd reads the operand as C would: 010 is eight and 0x10 is sixteen. GNU dd reads it in base 10 and treats x as its own multiplication suffix, so 0x10 is 0 × 10 = 0 — it warns, exits 0, and copies from the start of the file. Take the two rows together and the same command reads a different part of the same file on the two machines, and the silent one is skip=010: eight bytes in on a Mac, ten on Linux, no diagnostic on either. Write skip=8, or skip=0x10 on a Mac only, and check which dd you are talking to before pasting a command out of somebody's blog post.

Where the convention comes from: one argument

None of the disagreements above is a bug, and none of them is deep. Every one of those tools has to turn a string into a number, and the C library function for that takes the base as an int argument — where 0 does not mean base zero, it means read the prefix and decide:

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

1. ONE ARGUMENT, AND IT IS THE WHOLE CONVENTION
   input    base          value  consumed   left over
   010      0                 8         3   (nothing)
   0x10     0                16         4   (nothing)
   10       0                10         2   (nothing)
   09       0                 0         1   9
   0        0                 0         1   (nothing)

   010      10               10         3   (nothing)
   0x10     10                0         1   x10
   10       10               10         2   (nothing)
   09       10                9         2   (nothing)
   0        10                0         1   (nothing)

2. READ THE TWO BLOCKS AGAINST EACH OTHER
   010   is 8 under base 0 and 10 under base 10. Same digits, same
         function, one argument apart -- and no error either way.
   09    is where base 0 gives up: the leading zero said octal, 9 is
         not an octal digit, so it returns 0 and leaves "9" behind.
   0x10  is where base 10 gives up, the same way round: it reads the
         0, stops at the x, and returns 0 with "x10" left over.

3. AND BOTH GIVE-UPS RETURN 0, WHICH IS ALSO A CORRECT ANSWER
   The last row is a real "0", parsed in full. Nothing in the return
   value separates it from the two failures above -- only `consumed`
   does, and a caller that ignores the endptr cannot tell them apart.
   That is the same claim the rest of this library keeps making about
   text: the value is not the whole result, and the base is not in the
   digits. Here it is one int argument, chosen by whoever wrote the
   tool, in a call you never see.

So "010 means eight" is not a fact about digits or about C — it is what one argument does, chosen once by whoever wrote each tool, in a call you never see. Measured by their behaviour on the section above: xxd -s, od -j, bash's printf and BSD dd all read their argument the way base 0 does, and head -c, sort -n, test and GNU dd read it the way base 10 does. Both are ordinary choices, neither appears on the synopsis line of any of those man pages, and the only way to be immune to the difference is to type a spelling the two readings agree about.

The last section of that program is the same shape as the rest of this library's arguments about text: the value is not the whole result. strtol returns 0 for a real "0", for "09" under base 0, and for "0x10" under base 10, and the only thing separating a parse from a give-up is the end pointer — the strtol section of the neighbouring page has that in full, including which of its error signals are portable.

Python: five readers of one field, in one standard library

Python makes the base an argument too, and then ships several functions that have already chosen one. Put the same dotted quad through them and the answers are not on speaking terms:

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

1. ONE FIELD OF FOUR CHARACTERS, AND FIVE READERS IN ONE STANDARD LIBRARY
   int('010')                         -> 10
   int('010', 8)                      -> 8
   int('010', 2)                      -> 2
   int('010', 0)                      -> ValueError   (refused)
   inet_ntoa(inet_aton('010.0.0.1'))  -> 8.0.0.1
   ipaddress.ip_address('010.0.0.1')  -> ValueError   (refused)
   Ten, eight, two, a refusal, 8.0.0.1 and a refusal. Nothing in '010' picked
   one of those; each reader brought its own base and none of them said so.

2. AND inet_aton TAKES FOUR MORE SPELLINGS NOBODY TYPED ON PURPOSE
   inet_aton('010.0.0.1')             -> 8.0.0.1
   inet_aton('0x8.0.0.1')             -> 8.0.0.1
   inet_aton('134744072')             -> 8.8.8.8
   inet_aton('10.1')                  -> 10.0.0.1
   inet_aton('0177.1')                -> 127.0.0.1
   inet_aton('8.8.8.8')               -> 8.8.8.8
   A leading zero is octal, 0x is hex, a bare number is the whole 32-bit
   address, and a dotted address may have four parts, three, two or one --
   all of it documented in inet_aton(3), and all of it older than DNS.
   The last two are the ones that end up in a security report: 0177.1 is
   127.0.0.1, so an allow-list that compares the STRING and a connect()
   that PARSES it are two readers of one field, disagreeing about a base
   nobody wrote down. That is the classic SSRF filter bypass.

3. WHICH IS WHY ipaddress REFUSES EVERY ONE OF THEM
   ip_address('010.0.0.1')            -> ValueError   (refused)
   ip_address('0x8.0.0.1')            -> ValueError   (refused)
   ip_address('134744072')            -> ValueError   (refused)
   Not an oversight in the older function and not a fix to it -- the two
   have different jobs. CPython's own comment in ipaddress.py says which
   standard it chose to be as strict as, and names the bug that made it:
      # Handle leading zeros as strict as glibc's inet_pton()
      # See security bug bpo-36384

4. A PREFIX IS ACCEPTED ONLY WHERE IT AGREES WITH THE BASE YOU NAMED
   int('0xFF', 16)                    -> 255
   int('0xFF')                        -> ValueError   (refused)
   int('0xFF', 10)                    -> ValueError   (refused)
   int('FF', 16)                      -> 255
   int('0b1010', 2)                   -> 10
   int('0o377', 8)                    -> 255
   int('0x41', 0)                     -> 65
   So the prefix is never the argument -- `base` is. int(s, 0) is the one
   reading that asks the string, and it is also the one that refuses '010',
   because a leading zero was octal in C and Python 3 declined to inherit it.

5. 'IS IT DIGITS' IS A DIFFERENT QUESTION FROM 'WILL int() TAKE IT'
   string     isdigit()   isdecimal()   int()
   '42'       True        True          42
   '٤٢'       True        True          42
   '²'        True        False         ValueError
   Row 2 is two ARABIC-INDIC digits and int() reads them as 42. Row 3 is
   SUPERSCRIPT TWO: isdigit() says yes and int() refuses it, so the guard
   everyone writes -- `if s.isdigit(): int(s)` -- raises on a string it just
   approved. isdecimal() is the one that matches int(); better still, try it.

None of that is a bug in inet_aton. It is the older Berkeley parser, and its man page says so in as many words: the parts of a dotted address "may be decimal, octal, or hexadecimal, as specified in the C language" — the same base-0 rule as the section above — and the dotted form itself may have four parts, three, two or one, so 134744072 is a whole address and 10.1 is 10.0.0.1. ipaddress refuses every one of those, and CPython says in the source why: # Handle leading zeros as strict as glibc's inet_pton(), with # See security bug bpo-36384 on the line under it. That is the shape of the real-world failure: an allow-list that compares the string and a connect() that parses it are two readers of one field, and a base nobody wrote down is what separates them. It is a parser differential with a number in it.

One function in that family cannot go in an answer key, and the reason is worth knowing:

Measured 2026-09-12, both under CPython 3.14.7

  socket.inet_pton(AF_INET, '010.0.0.1')
    macOS 26                      -> 10.0.0.1      reads it as decimal, no complaint
    Debian (python:3.14-slim)     -> OSError       illegal IP address string

inet_pton is supposed to be the strict one, and on glibc it is. On macOS it accepts the leading zero and reads it in base 10 — a third answer, differing from inet_aton's eight on the same machine and from glibc's refusal on the same line of Python. ipaddress is the only one of the three that answers the same way everywhere, which is the argument for using it: it is a parser this library can make a claim about.

And the digits themselves are not settled either. int() accepts any Unicode decimal digit in any base, so int('٤٢') is 42 — measured, with the hex case, on Hex: a number, or a picture of bytes. The new half above is the guard: '²'.isdigit() is True and int('²') raises, so if s.isdigit(): int(s) throws on a string it has just approved. str.isdecimal() is the predicate that matches int(); better still, try the conversion and catch.

A field with a default, and a screenshot that cannot show it

Which leaves the case that has no notation at all. 010 Editor's manual ↗ documents its input fields plainly, and one line in it is the whole lesson: the format is assumed to be decimal, and "some fields have a Decimal and Hex toggle beside them" which changes that default. Everything else on the page follows from it:

  • The tool needs six notations — 0x100, 3f,h, d2,x, FFh, 377,o, 0101,b — plus ,d for decimal. A spelling that says "this is decimal" is only necessary in a field where decimal is not reliably the default. That one suffix is the invisible default made visible.
  • The same product documents that "the number formats supported in Scripts and Templates are slightly different", so a value copied from a field into a script is not guaranteed to mean the same thing.
  • And 'A' in one of those fields is 65. That is true, and it is true only for ASCII: the same field given 'é' has to decide whether one character is one byte, one code point, or two bytes, which is the question the rest of this library is about — start at A character is a number.

The practical consequence is about bug reports, and it is the reason this page exists rather than a note on another one. A toggle is UI state: it does not appear in the digits, it does not survive a copy-paste, and a screenshot often crops it out. So "I searched for 41 and it did not find it" is not a reproducible claim — in a hex-editor search box those two characters are one byte 0x41 in hex mode and two bytes 34 31 in text mode, which is the number-or-bytes distinction with a radio button on it. Quote the spelling, not the digits: say 0x41, or 41h, or "41, with the Hex toggle on", and the ambiguity is gone before anyone has to guess.

Rust is the one language here that will not let the question go unanswered, and it does it by making the base a parameter with no default: u8::from_str_radix(s, 8) cannot be called without naming 8, there is no base 0 mode to infer a prefix, and the prefix is not even accepted — from_str_radix("0x41", 16) is an error, measured on Hex is a shorthand. That is the trade the whole page describes, made explicit: nothing is inferred, so nothing can be inferred wrongly, and the cost is that you can never leave it out.

If you are coming from Python or ABAP

Python. The base is a keyword-free second argument and it has three modes, which is two more than most people use: int(s) is base 10, int(s, 16) names a base, and int(s, 0) is the C-style "read the prefix" reading — the same mode as strtol's 0, with one deliberate difference, since Python refuses '010' rather than calling it octal. Three habits transfer from this page. Pass the base explicitly whenever the string came from outside your program, because the default is a decision and it should be visible in the code that makes it. Reach for int(s, 0) only when you genuinely want the input to choose, which is rare in a config file and never right in a form. And prefer ipaddress to socket.inet_aton for anything that will later be compared, allow-listed or logged.

ABAP. There is no base prefix for an integer literal at all — no 0x, no 0b, and no leading-zero octal rule to trip over. A hexadecimal constant is a character string assigned to a byte field, DATA lv_x TYPE x LENGTH 1 VALUE 'FF', so the base is carried by the type rather than by the notation: an x field is always read as hex, in source, in the debugger and in a write-out, and an i field is always decimal. That is closer to Rust's answer than to C's — the base is never inferred from the digits — and it means the mistake this page is about arrives from the other direction — as a conversion between an x/xstring and an i, where the question is byte order and width rather than base — rather than as a field that quietly changed base. Where it does bite is GET BIT / SET BIT, which number bits from 1 on the left; see the bridge on Counting in hexadecimal. (Not machine-checked — CI cannot run ABAP.)

Try it

  1. Dump a file you know — xxd -g 4 somefile | head -5 — pick a row from its offset column, and seek to it twice: once with the digits exactly as printed, once with 0x in front. Which of the two landed where you meant?
  2. Take a zero-padded number out of a real CSV or log line, 08 or 09 or 010, and run it through both brackets: [ "$n" -eq 8 ] and [[ "$n" -eq 8 ]]. Then find out which one your own scripts use.
  3. Open the number field of whatever hex editor or debugger you use, type 100, and find out what value it took. Then find the control that changes the default, and note whether it would appear in a screenshot of the window.
  4. If you have a program that accepts an address, a port, a mask or an ID as text, hand it 010.0.0.1, 0x8.0.0.1 and a plain 134744072 and see which of the three it accepts. In Python, socket.inet_aton takes all three and ipaddress.ip_address takes none.
  5. Grep your own code for int( on a string that came from a file, a form or an environment variable, and check how many of those calls state a base. That is the invisible default living in your project rather than in someone's tool.

Practice

Say which base the field is in. Five questions, each with one answer that is the same on every machine. Predict all five, then run them.

  1. $(( 0x10 )), $(( 010 )) and $(( 10#010 )) — three numbers.
  2. A dump's third row is labelled 00000020. Give a xxd -s argument that seeks there, then say where -s 20 and -s 020 land instead.
  3. Which of [ 010 -eq 8 ] and [[ 010 -eq 8 ]] is true?
  4. xxd -s 010 and head -c 010 are given the same four characters. Which base does each one read them in?
  5. chmod 644 sets rw-r--r--. What is that mode as a decimal number, and what would happen if you handed chmod the digits 644 meaning decimal?
Answers

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

1. THREE SPELLINGS IN $(( ))
   $(( 0x10 ))      = 16
   $(( 010 ))       = 8
   $(( 10#010 ))    = 10
   Sixteen, eight, ten. The first says its base, the second says it in a
   character you can miss, and the third says the base that needs saying
   precisely because it is the one everybody assumes.

2. SEEKING TO THE ROW LABELLED 00000020
   00000000: 30313233 34353637 38396162 63646566  0123456789abcdef
   00000010: 30313233 34353637 38394142 43444546  0123456789ABCDEF
   00000020: 2e2e2e2e 2e2e2e2e 2e2e2e2e 2e2e2e2e  ................
   The third row's label is 00000020, and that is hex, so it is byte 32.
   xxd -s 0x20    2e2e2e2e
   xxd -s 32      2e2e2e2e
   xxd -s 20      34353637
   xxd -s 020     30313233
   The first two are the same place. -s 20 is decimal twenty, twelve bytes
   early; -s 020 is octal sixteen, which lands on the row above. Three
   spellings of "twenty-ish", three different bytes, and no complaint from
   any of them. Type the 0x and the question does not arise.

3. THE SAME TEST, TWO BUILTINS
   [ 010 -eq 8 ]        false
   [[ 010 -eq 8 ]]      true
   [[ ]] evaluates its operands arithmetically, so 010 is octal eight and the
   test passes. [ ] reads them in base 10, so 010 is ten and it fails.

4. FOUR CHARACTERS, TWO FLAGS
   xxd -s 010       38396162   (seeks to byte 8)
   head -c 010      0123456789   (reads ten bytes)
   One of them called strtol with base 0 and the other with base 10. Nothing
   on either man page's synopsis line tells you which, and both are ordinary
   choices for a tool to make.

5. THE NUMBER YOU HAVE MEMORISED IS OCTAL
   chmod 644 means octal    0644 = 420 decimal
   so decimal 644 would be  01204 octal
   and chmod 644 gives      -rw-r--r--
   rw-r--r-- is 420 as a decimal number. Nobody writes it that way, because
   chmod's field has exactly one base and no syntax for saying so -- which is
   the cleanest case on the page: an invisible default nobody trips over,
   because it never varies and the tool refuses the digits 8 and 9.

See also