Number Base Conversion in C — Binary, Octal, Decimal and Hex

Six conversion functions are really two algorithms. A tested base converter for every base from 2 to 36, and what the old menu program got wrong.

The same quantity shown in three different numeral bases, illustrating number base conversion in C

Feed the program currently published on this page the string zz and ask it to read it as octal, and it answers 666. Ask it to convert the decimal number 0 to binary and it prints nothing at all — not 0, not an error, just an empty line. Neither failure produces a warning, and neither is unusual: they are what happens when a converter validates nothing and uses the wrong loop.

This rewrite replaces that program. You get the two algorithms that underlie every base conversion, a single pair of functions that handles every base from 2 to 36 instead of six near-duplicates, the standard-library call that already does half the job, and a captured record of exactly what the old code did wrong. If you are new to C, the Hello World walkthrough is the better starting point. Every program on this page was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 (-std=c11 -Wall -Wextra, zero warnings), with a clean AddressSanitizer and UndefinedBehaviorSanitizer run on the corrected version; the C23 %b examples were checked on GCC 13.3 and GCC 14.2. Every output block, warning and sanitizer report is captured verbatim.

What Is Number Base Conversion?

Number base conversion changes how a number is written, not what it is. The value 156 in base 10, 234 in base 8, and 10011100 in base 2 are the same quantity in three notations. Converting between them needs only two operations: repeated division by the target base to produce digits, and repeated multiplication by the source base to consume them.

That second sentence is the part most tutorials skip. A program offering “binary to decimal”, “decimal to binary”, “octal to decimal”, “decimal to octal”, “octal to binary” and “binary to octal” looks like six problems. It is two, applied in different orders — and once you see that, the six functions collapse into a pair that also handles hexadecimal, base 36 and anything in between.

Base conversion both ways: 156 (base 10) = 234 (base 8) Decimal → base 8 — divide repeatedly, keep the remainders 156 ÷ 8 = 19 19 ÷ 8 = 2 2 ÷ 8 = 0 remainder remainder remainder 4 3 2 read the remainders bottom to top → 234 Stop when the quotient reaches 0. Use a do-while so that an input of 0 still emits one digit. Base 8 → decimal — multiply by the base, add the next digit start = 0 0 × 8 + 2 = 2 2 × 8 + 3 = 19 19 × 8 + 4 = 156 read the digits left to right no pow(), no exponents, no floating point The same intermediates, 2 and 19, appear in both directions: one algorithm undoes the other.
Base conversion runs in both directions: 156 in base 10 is 234 in base 8. Dividing repeatedly by 8 and reading the remainders bottom to top produces the digits; multiplying by 8 and adding each digit left to right consumes them. The intermediate values 2 and 19 appear in both traces because each algorithm reverses the other.

The Two Algorithms

Decimal to any base: divide and collect remainders

Divide by the target base. The remainder is a digit. Replace the number with the quotient and repeat until the quotient is zero. The digits come out least-significant first, so you reverse them — or, as below, write them into a buffer backwards and copy them out forwards.

Converting 156 to base 8:

156 / 8 = 19  remainder 4
 19 / 8 =  2  remainder 3
  2 / 8 =  0  remainder 2

Read the remainders bottom to top: 234.

The loop must be a do/while, not a while. A while (value > 0) loop never executes when the value is already zero, which is precisely the bug in the published version — it emits an empty string for input 0. A do/while runs its body once before testing, so zero produces the single digit 0. That one-keyword difference is the whole fix.

Any base to decimal: multiply and add

Start with an accumulator of zero. For each digit left to right, multiply the accumulator by the base and add the digit’s value. This is Horner’s method, and it needs no exponents, no pow() and no floating point:

start        =   0
  0 x 8 + 2  =   2
  2 x 8 + 3  =  19
 19 x 8 + 4  = 156

Notice that 2 and 19 appear in both traces. That is not a coincidence — the multiply-and-add chain is the divide-and-collect chain run backwards, which is why one algorithm undoes the other.

A Converter for Every Base From 2 to 36

Here is the whole thing. Two functions replace the original six, and they handle hex and base 36 for free.

/* baseconv.c - convert between any bases 2..36 */
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const char DIGITS[] = "0123456789abcdefghijklmnopqrstuvwxyz";

/* Format `value` in `base`, writing into out[0..outsz-1].
   Returns out on success, NULL if the base is invalid or the buffer is too small. */
char *to_base(unsigned long value, int base, char *out, size_t outsz)
{
    if (base < 2 || base > 36 || out == NULL || outsz == 0) {
        return NULL;
    }

    /* Worst case is base 2: one character per bit, plus the terminator. */
    char tmp[sizeof(unsigned long) * CHAR_BIT + 1];
    size_t n = 0;

    /* do-while, not while: a do-while runs once even when value is 0,
       which is what makes to_base(0, ...) produce "0" instead of "". */
    do {
        tmp[n++] = DIGITS[value % (unsigned long)base];
        value /= (unsigned long)base;
    } while (value != 0);

    if (n + 1 > outsz) {
        return NULL;
    }

    for (size_t i = 0; i < n; i++) {
        out[i] = tmp[n - 1 - i];   /* tmp holds the digits backwards */
    }
    out[n] = '\0';
    return out;
}

/* Parse `s` as a number in `base`. Returns false and leaves *out untouched
   if the string is empty, contains a character that is not a digit in that
   base, has trailing text, is negative, or overflows. */
bool from_base(const char *s, int base, unsigned long *out)
{
    if (s == NULL || *s == '\0' || base < 2 || base > 36 || out == NULL) {
        return false;
    }
    /* strtoul accepts a leading sign and skips leading whitespace. Reject both,    so that " 1010" and "1010 " are treated the same way. */
    if (s[0] == '-' || s[0] == '+' || isspace((unsigned char)s[0])) {
        return false;
    }

    errno = 0;
    char *end;
    unsigned long value = strtoul(s, &end, base);

    if (end == s)          return false;  /* no digits at all */
    if (*end != '\0')      return false;  /* trailing garbage: "1012" in base 2 */
    if (errno == ERANGE)   return false;  /* too large for unsigned long */

    *out = value;
    return true;
}

Driving it through the six original conversions plus four the old program could not do:

Output:

base  2 -> base 10: 1010   -> 10
base 10 -> base  2: 10     -> 1010
base  8 -> base 10: 777    -> 511
base 10 -> base  8: 511    -> 777
base  8 -> base  2: 777    -> 111111111
base  2 -> base  8: 1010   -> 12
base 10 -> base  2: 0      -> 0
base 10 -> base  8: 0      -> 0
base 10 -> base 16: 255    -> ff
base 16 -> base 10: ff     -> 255
base 36 -> base 10: zz     -> 1295

And the inputs that used to slip through:

Output:

Inputs the old program accepted silently:
  from_base("zz", 8) -> rejected
  from_base("99", 8) -> rejected
  from_base("hello", 2) -> rejected
  from_base("2222", 2) -> rejected
  from_base("", 2) -> rejected
  from_base("-5", 2) -> rejected
  from_base("1010 ", 2) -> rejected

zz is rejected as octal and accepted as base 36, where it legitimately means 1295. That is the difference between validating against the base and not validating at all.

Three details in from_base are worth calling out, because each one closes a hole the original left open. Checking *end != '\0' catches trailing garbage — without it, "1012" parsed as binary would quietly return 2, having stopped at the 2. Checking errno == ERANGE catches overflow. And rejecting a leading - is necessary because strtoul does not: it negates the result using unsigned wraparound, so "-1" comes back as 18446744073709551615 rather than an error.

What from_base accepts. Digits valid in the given base, in either case — "FF" and "ff" are both 255 in base 16. Nothing else: no sign, no surrounding whitespace, no trailing text, no value larger than unsigned long. One inherited behaviour is worth knowing about: strtoul consumes a 0x prefix when the base is 16, so "0x10" is accepted and returns 16. That is the C library’s documented behaviour rather than a bug, but if your input format forbids the prefix you have to reject it yourself. Output from to_base is always lowercase, because the DIGITS table is.

C Already Does Half of This For You

Before writing any conversion code, it is worth knowing how much of the job the standard library already does.

Parsing from any base is solved. strtol and strtoul take a base argument from 2 to 36 and have since C89. The from_base function above is a thin validation wrapper around one, not a reimplementation.

Three output bases are built into printf.

#include <stdio.h>
int main(void) { int v = 255; printf("dec=%d oct=%o hex=%x HEX=%X\n", v, v, v, v); return 0; }

Output:

dec=255 oct=377 hex=ff HEX=FF

That is decimal-to-octal and decimal-to-hex done — two of the original program’s six menu options, in one line each.

C23 adds binary. The %b conversion specifier is new in C23 and makes decimal-to-binary a printf call too:

#include <stdio.h>
int main(void) { printf("%b\n", 42u); printf("%#b\n", 42u); return 0; }

Output:

101010
0b101010

Compiled with gcc -std=c2x on GCC 13.3 and gcc-14 -std=c23 on GCC 14.2 — both accepted it and both produced the output above, against glibc 2.39. That pairing matters: %b is interpreted by your C library’s printf, not by the compiler, which only checks the format string. A new compiler in front of an older C library will compile the call and print the wrong thing. Check both before relying on it.

ConversionStandard-library answerSince
Any base to integerstrtol / strtoul with a base argumentC89
Integer to octalprintf("%o", v)C89
Integer to hexprintf("%x", v)C89
Integer to binaryprintf("%b", v)C23
Integer to base 3, 7, 36…Nothing — write to_base

So the only conversion genuinely worth hand-writing is integer-to-arbitrary-base. Everything else already exists, and itoa() — which many tutorials reach for — is not in any C standard and is not portable.

What the Published Program Actually Did

Every result below is a captured run of the code as it appeared on this page, compiled with GCC 13.3.

It does not build at all with the command most beginners try, because pow() lives in the math library:

/usr/bin/ld: /tmp/ccxLQsEt.o: in function `binaryToDecimal':
old.c:(.text+0x3b1): undefined reference to `pow'
/usr/bin/ld: /tmp/ccxLQsEt.o: in function `octalToDecimal':
old.c:(.text+0x527): undefined reference to `pow'
collect2: error: ld returned 1 exit status

The fix is to add -lm, and the article never mentioned it. (Our math.h reference covers the linking rule in more detail.) With -lm supplied, it compiles cleanly under -Wall -Wextra — which is the problem. Every failure below is silent.

InputMenu optionWhat it printedWhat it should have done
02 (decimal to binary)(empty line)print 0
04 (decimal to octal)(empty line)print 0
-52 (decimal to binary)(empty line)reject the input
hello1 (binary to decimal)0reject the input
22221 (binary to decimal)0reject the input
993 (octal to decimal)81reject the input
zz3 (octal to decimal)666reject the input
32 × 11 (binary to decimal)-21474836484294967295

The zero case in full, exactly as the terminal shows it:

Number Base Conversion Program
1. Binary to Decimal
2. Decimal to Binary
3. Octal to Decimal
4. Decimal to Octal
5. Octal to Binary
6. Binary to Octal
Enter your choice: Enter decimal number: Binary equivalent:

The zz result is the most instructive. octalToDecimal computes (octal[i] - '0') * pow(8, j) without ever asking whether the character is an octal digit. For 'z', 'z' - '0' is 74, so the function faithfully computes 74 × 8 + 74 = 666 and returns it as though it meant something.

The last row is a different failure. binaryToDecimal accumulates into an int, so 32 binary ones overflow signed arithmetic. Signed overflow is undefined behaviour in C; this machine produced -2147483648, and another compiler or optimisation level is entitled to produce something else. Using unsigned long, as the rewrite does, makes the arithmetic well defined and raises the ceiling.

Finally, the input buffer. scanf("%s", input) against char input[50] has no bound, so AddressSanitizer catches an 80-character input immediately:

==153==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f6715300092
WRITE of size 81 at 0x7f6715300092 thread T0
    #0 0x7f6717491acf in scanf_common
Address 0x7f6715300092 is located in stack of thread T0 at offset 146 in frame
    #0 0x5620c95c82f8 in main /home/claude/base/old.c:13
SUMMARY: AddressSanitizer: stack-buffer-overflow in scanf_common

The fix is a field width: scanf("%49s", input), or better, fgets. The same sanitizer run against the rewritten program produced zero output.

Why pow() Does Not Belong in a Base Converter

The original uses pow(2, j) and pow(8, j) to weight each digit. The common objection is that floating-point pow returns inexact results for integer powers. I tested that claim and it did not hold up here.

Comparing (long)pow(2, j) against 1L << j for every j from 0 to 30, and pow(8, j) against an integer product for j from 0 to 10, glibc’s pow was exact in every case — zero mismatches. The rounding argument is real in principle and was not reproducible on this toolchain, so it is not the reason to avoid pow here.

The two real reasons are range and speed.

Range fails first. pow(2, 31) returns 2147483648.0, which does not fit in an int; assigning it produced 2147483647 on this machine, and the conversion is undefined behaviour rather than a defined saturation. That happens at a 32-digit binary string — not an exotic input, and exactly the case the failure table above ends on.

Speed is the larger surprise.

How this was measured

SettingValue
Date testedSeptember 2026
MachineSingle-core Linux VM, Ubuntu 24.04
CompilerGCC 13.3, -O2 -std=c11
InputOne fixed 31-character binary string
Iterations200,000 warm-up discarded, then mean over 5,000,000 calls
StatisticMean wall-clock nanoseconds per call
Correctness checkBoth versions asserted to return 1450748586 before timing
Not capturedNo median, no percentiles, no CPU-frequency control

Both functions are marked __attribute__((noinline)) and their results accumulate into a volatile sink, because without that -O2 hoists the whole computation out of the loop and the benchmark measures nothing.

ImplementationPer callRelative
pow(2, j) per digit232 ns18× slower
Horner (d = d * 2 + digit)13 nsbaseline

Reproduce it. The two figures come from these commands:

# build with the optimiser on; -lm is only needed by the pow() version
gcc -O2 -std=c11 -D_POSIX_C_SOURCE=199309L bench.c -o bench -lm

# confirm both implementations agree before timing anything
./bench          # prints the shared result, then the per-call means

The harness marks both functions __attribute__((noinline)), accumulates into a volatile sink, discards 200,000 warm-up calls and averages the next 5,000,000. Drop either the noinline or the sink and -O2 hoists the whole loop, at which point the benchmark measures nothing and reports it confidently.

Across three runs the ratio held between 17.8× and 18.4×. A floating-point library call per digit, plus a double-to-int conversion per digit, to do work that is one integer multiply and one add. The Horner version is also shorter, has no -lm dependency and cannot overflow a double. For base-2 specifically, the multiply is a shift — see our guide to bitwise and shift operators in C for why the compiler will make that substitution for you.

A quick review question that catches more bugs than it should: what does this function do with zero, and what does it do with garbage? Run it on both before you run it on anything else. The converter this page used to carry passed every test its author tried and failed both of these — it printed an empty line for zero and returned 666 for the string zz. Neither is a crash, so neither shows up in testing that only checks whether the program ran. A function that cannot say no is a function that will eventually say something wrong.

Key Takeaways

  • Six conversion functions are really two algorithms. Divide-and-collect-remainders produces digits; multiply-and-add consumes them. Write them once and every base from 2 to 36 works.
  • Use do/while, not while, in the division loop. A while loop emits an empty string for input 0 — the exact bug in the version this replaces.
  • Validate against the base, or you will convert nonsense silently. Reading zz as octal returned 666 rather than an error, because nothing checked whether the characters were octal digits.
  • strtol and strtoul already parse any base from 2 to 36, and have since C89. Wrap them for validation rather than reimplementing them.
  • printf already outputs octal and hex with %o and %x, and C23 adds binary with %b where your C library implements it — three of the original six menu options need no code at all.
  • Skip pow() for digit weights. On this machine it was 18× slower than Horner’s method, it drags in -lm, and it breaks at 2³¹ where integer arithmetic keeps working. Integer types overflow too — Horner’s method removes the floating-point step, not the need to size your result type and check the range.
  • Bound your input. scanf("%s", buf) against a fixed array is a buffer overflow waiting for a long line; use scanf("%49s", buf) or fgets.

Frequently Asked Questions

Conclusion

The program this page used to carry was not badly written so much as unvalidated. It handled the inputs its author tried and produced confident nonsense for everything else, which is the failure mode that survives longest in tutorial code — it never crashes, so nobody investigates. Converting zz to 666 is funny; converting a mistyped serial number to a plausible-looking integer in production is not.

The wider lesson is worth more than the conversion routine. Before hand-writing a standard operation, check whether the standard library already does it, and when you do write it, decide explicitly what your function should refuse. The rest of our C programming articles follow the same approach: working code, captured output, and the failure cases spelled out.

What I Could Not Verify

Everything on this page was produced on one machine: a single-core Ubuntu 24.04 VM with GCC 13.3, plus GCC 14.2 for the second C23 %b check. No other compiler or platform was tested — the pow exactness result in particular is a property of this glibc build and could differ elsewhere, which is why it is reported as a negative result rather than a general rule. The signed-overflow output of -2147483648 is undefined behaviour and should not be relied on; a different compiler or optimisation level may print something else. The timing ratio held across three runs on one machine; the ordering should transfer, the nanosecond figures will not.

Scroll to Top