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 which — 11111111, 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,dfor 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¶
- 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 with0xin front. Which of the two landed where you meant? - Take a zero-padded number out of a real CSV or log line,
08or09or010, and run it through both brackets:[ "$n" -eq 8 ]and[[ "$n" -eq 8 ]]. Then find out which one your own scripts use. - 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. - 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.1and a plain134744072and see which of the three it accepts. In Python,socket.inet_atontakes all three andipaddress.ip_addresstakes none. - 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.
$(( 0x10 )),$(( 010 ))and$(( 10#010 ))— three numbers.- A dump's third row is labelled
00000020. Give axxd -sargument that seeks there, then say where-s 20and-s 020land instead. - Which of
[ 010 -eq 8 ]and[[ 010 -eq 8 ]]is true? xxd -s 010andhead -c 010are given the same four characters. Which base does each one read them in?chmod 644setsrw-r--r--. What is that mode as a decimal number, and what would happen if you handedchmodthe digits644meaning 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¶
- Counting in hexadecimal — the odometer this page assumes, and the note on where octal's three bits do not fit a byte
- Hex is a shorthand — the source-code half: four prefixes, the underscore separator, and
0755as three different numbers in three languages - Hex: a number, or a picture of bytes — the same field read as a quantity or as data,
strtol's portable error checks, andint()on Arabic-Indic digits - Reading a hex dump — where the hex offset column comes from that
xxd -swill not read back printfwrites bytes — the escapes with a base in them:\101is octal and\x41is hex, in the same stringodreads types, not bytes — why the default output base is 8, and the three flags that change it- Two readers, one byte string — the general shape of the
inet_atonfinding, with bytes instead of digits - Writing a number down ↗ — the sibling Rust library on literals in all four bases, separators, and the width written on the number