Skip to content

Comparing signed and unsigned

Level: 201 · working knowledge

One line: Rust will not compare an i32 with a u16 until you convert one of them, and C compiles every mixed comparison because it converts both sides to one common type first — harmless when a small type is promoted to int, and wrong when an int meets an unsigned int, where -1 < 1u is false because the -1 has become UINT_MAX.

Rust in Action (Tim McNamara, Manning 2021) opens §2.3.3, "Comparing numbers", with a comparison the Rust compiler refuses: a signed 32-bit integer against an unsigned 16-bit one. Here is that refusal, from a small Rust program that is not one of this library's examples:

fn main() {
    let a: i32 = 10;
    let b: u16 = 100;
    if a < b { println!("less"); }
}
Real output — rustc 1.98.0 --edition 2024 cmp.rs, x86-64 macOS 26
error[E0308]: mismatched types
 --> cmp.rs:4:12
  |
4 |     if a < b { println!("less"); }
  |        -   ^ expected `i32`, found `u16`
  |        |
  |        expected because this is `i32`
  |
help: you can convert a `u16` to an `i32`
  |
4 |     if a < b.into() { println!("less"); }
  |             +++++++

C accepts that line and every other comparison between two integer types. It does not compare mixed types, though: it converts both operands to one type first, by rules fixed in the standard, and then compares. For most pairs the rules pick a type that holds both values and nothing surprising happens. For one common pair they do not, and the program says -1 is not below 1.

C converts first, then compares

signed_unsigned_c.c makes each kind of comparison, converts 300 to unsigned char, then tries the two fixes:

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

/* C compiles every comparison between two integer types; it converts both
   sides to one common type first, and that conversion decides the answer.
   Small types are promoted to int, where every value keeps its sign. An int
   that meets an unsigned int is converted to unsigned, and -1 becomes the
   largest unsigned int there is. The two fixes at the end compare in a type
   that holds both ranges, or look at the sign before converting. */
#include <float.h>
#include <limits.h>
#include <stdio.h>

/* Some answers below lean on type widths, which C leaves to the platform. These
   lines make them promises: a machine where one fails will not build this. */
_Static_assert(USHRT_MAX <= INT_MAX, "int holds every unsigned short");
_Static_assert(UINT_MAX <= LLONG_MAX, "long long holds every unsigned int");
_Static_assert(UINT_MAX == 4294967295u, "unsigned int is 32 bits");
_Static_assert(CHAR_BIT == 8, "a char is 8 bits");
_Static_assert(DBL_MANT_DIG == 53, "a double has 53 significant bits");

int main(void)
{
    /* Integer promotion: an unsigned short fits in an int, so both sides
       become int and the comparison is the one you meant. */
    int ten = 10;
    unsigned short hundred = 100;
    short minus_one_short = -1;
    unsigned short one_short = 1;
    printf("int 10 < unsigned short 100        -> %d\n", ten < hundred);
    printf("short -1 < unsigned short 1        -> %d\n", minus_one_short < one_short);

    /* The usual arithmetic conversions: int and unsigned int have the same
       rank, so the unsigned type wins and -1 is converted to it. */
    int minus_one = -1;
    unsigned int one = 1;
    printf("int -1 < unsigned int 1            -> %d\n", minus_one < one);
    printf("  because (unsigned int)-1         =  %u\n", (unsigned int)minus_one);
    printf("  which is UINT_MAX                =  %u\n", UINT_MAX);
    printf("-1L < 1UL                          -> %d\n", -1L < 1UL);

    /* The same rules for an integer against a double: the integer converts,
       and 2^53 + 1 has no double of its own, so it lands on its neighbour's. */
    long long two_53_plus_1 = 9007199254740993LL;
    printf("2^53 + 1 == (double)(2^53 + 1)     -> %d\n",
           two_53_plus_1 == (double)two_53_plus_1);

    /* Converting to an unsigned type is defined for every value: it wraps
       modulo 2^N. 300 - 256 = 44. */
    int three_hundred = 300;
    printf("(unsigned char)300                 -> %d\n", (unsigned char)three_hundred);

    /* Fix one: convert both sides to a type that holds both ranges. */
    printf("(long long)-1 < (long long)1u      -> %d\n",
           (long long)minus_one < (long long)one);

    /* Fix two: a negative int is below every unsigned value, so say so
       before the conversion gets a chance to change it. */
    printf("-1 < 0 || (unsigned int)-1 < 1u    -> %d\n",
           minus_one < 0 || (unsigned int)minus_one < one);
    return 0;
}

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

int 10 < unsigned short 100        -> 1
short -1 < unsigned short 1        -> 1
int -1 < unsigned int 1            -> 0
  because (unsigned int)-1         =  4294967295
  which is UINT_MAX                =  4294967295
-1L < 1UL                          -> 0
2^53 + 1 == (double)(2^53 + 1)     -> 1
(unsigned char)300                 -> 44
(long long)-1 < (long long)1u      -> 1
-1 < 0 || (unsigned int)-1 < 1u    -> 1

The first two lines are integer promotion (C17 6.3.1.1). Before any arithmetic or comparison, a value whose type ranks below int (a char, a short, their unsigned forms) is converted to int if int can hold every value of its type, and to unsigned int only if it cannot. An unsigned short fits in an int, so 10 < 100 compares two ints, and so does short -1 < unsigned short 1, which is true, as it should be. Promotion keeps the value, sign included. When the book calls widening a value "promotion", it is using the name C gives this rule.

The third line is the trap. int and unsigned int are already int-sized, so promotion leaves them alone and the usual arithmetic conversions (C17 6.3.1.8) decide: when the unsigned operand's type ranks at least as high as the signed one's, the signed operand is converted to the unsigned type. Converting -1 to an unsigned type is defined (C17 6.3.1.3): add one more than the type's maximum until the value fits, and -1 + (UINT_MAX + 1) is UINT_MAX. So the comparison C actually makes is 4294967295 < 1, and it is false. -1L < 1UL is the same rule one size up, and it is false too. Nothing is checked at run time: the conversion is the defined meaning of the expression.

The same rules cover an integer against a floating-point number: the integer is converted to double, and 2^53 + 1, which no double can hold, is rounded to a neighbouring double, the same one the right-hand side was rounded to. So they compare equal.

The _Static_assert lines at the top are there because some answers depend on type widths, which C leaves to the platform. short -1 < unsigned short 1 is true only where int is wider than short, the program prints 4294967295 only where unsigned int is 32 bits, (unsigned char)300 is 44 only where a char is 8 bits, and 2^53 + 1 rounds only where a double has 53 significant bits. The assertions turn those assumptions into build errors, so a machine where one fails refuses to compile the file and never prints a wrong answer. Both CI machines pass them.

Converting to an unsigned type always wraps. (unsigned char)300 is 300 - 256, which is 44, on every compiler where a char is 8 bits, which POSIX requires. The book's warning is that Rust's 300_i32 as i8 also gives 44. In C, converting to a signed type is a different rule, further down.

Ask the compiler, with -Wextra

-Wall does not warn about a comparison between signed and unsigned in C. The warning, -Wsign-compare, comes with -Wextra on both GCC and clang. sign_compare_warning_sh.sh builds demo/minus_one_below_one.c both ways with -Werror, so the exit status says whether the compiler complained:

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

#!/usr/bin/env bash
# Build the program whose -1 is not below 1 twice: with -Wall, then with -Wall
# -Wextra. -Werror turns a warning into a failed build, so the exit status says
# whether the compiler said anything, and then the program runs.
#
# Runs in a scratch copy of ../demo. GCC and clang word the warning
# differently, so the builds print only an exit status; the messages are on the
# page in fences that name the compiler.
set -u
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
cp ../demo/minus_one_below_one.c "$work"/
cd "$work" || exit 1

say()    { printf '$ %s\n' "$*"; eval "$*" 2>&1; }
status() { printf '$ %s\n' "$*"; eval "$*" >/dev/null 2>&1; echo "exit $?"; }

# -Wall alone says nothing about a signed/unsigned comparison in C.
status cc -std=c17 -Wall -pedantic -Werror -o minus_one minus_one_below_one.c
say ./minus_one

# -Wextra brings in -Wsign-compare, and -Werror makes it stop the build.
status cc -std=c17 -Wall -Wextra -pedantic -Werror -o minus_one minus_one_below_one.c

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

$ cc -std=c17 -Wall -pedantic -Werror -o minus_one minus_one_below_one.c
exit 0
$ ./minus_one
-1 is not below 1
$ cc -std=c17 -Wall -Wextra -pedantic -Werror -o minus_one minus_one_below_one.c
exit 1

-Wall builds the program without a word, and it prints -1 is not below 1. Adding -Wextra stops the build. The script records only exit statuses, because the two compilers word the warning differently. Here is each one building signed_unsigned_c.c, as tools/run_examples.py does:

Real output — GCC 13.3, cc -std=c17 -Wall -Wextra -pedantic, ubuntu:24.04 x86-64 in Docker
$ cc -std=c17 -Wall -Wextra -pedantic signed_unsigned_c.c -o signed_unsigned_c
signed_unsigned_c.c: In function 'main':
signed_unsigned_c.c:34:68: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
   34 |     printf("int -1 < unsigned int 1            -> %d\n", minus_one < one);
      |                                                                    ^
signed_unsigned_c.c:37:62: warning: comparison of integer expressions of different signedness: 'long int' and 'long unsigned int' [-Wsign-compare]
   37 |     printf("-1L < 1UL                          -> %d\n", -1L < 1UL);
      |                                                              ^
Real output — Apple clang 21, cc -std=c17 -Wall -Wextra -pedantic, x86-64 macOS 26
$ cc -std=c17 -Wall -Wextra -pedantic signed_unsigned_c.c -o signed_unsigned_c
signed_unsigned_c.c:34:68: warning: comparison of integers of different signs: 'int' and 'unsigned int' [-Wsign-compare]
   34 |     printf("int -1 < unsigned int 1            -> %d\n", minus_one < one);
      |                                                          ~~~~~~~~~ ^ ~~~
1 warning generated.

The wording is not the only difference. GCC also warns about -1L < 1UL, and clang does not: clang stays quiet when the signed operand is a constant, so -1 < u with a variable u gets no warning from clang either. Neither compiler warns about short -1 < unsigned short 1, because after promotion there is no mixed comparison left to warn about.

Converting to a signed type is the compiler's choice

Going the other way, a value that does not fit in a signed type, is not wrap-around by definition. C17 6.3.1.3 says the result is implementation-defined, or an implementation-defined signal is raised. demo/signed_char_300.c asks both compilers:

Real output — GCC 13.3, ubuntu:24.04 x86-64 in Docker (GCC 14.4 in Debian's gcc:14 image prints the same)
$ cc -std=c17 -Wall -Wextra -pedantic signed_char_300.c -o signed_char_300
$ ./signed_char_300
(signed char)300  -> 44
(signed char)-300 -> -44
(signed char)200  -> -56
Real output — Apple clang 21, x86-64 macOS 26
$ cc -std=c17 -Wall -Wextra -pedantic signed_char_300.c -o signed_char_300
$ ./signed_char_300
(signed char)300  -> 44
(signed char)-300 -> -44
(signed char)200  -> -56

They agree, and this library still does not record it as an answer key. The results are the implementation's choice, the same category as sizeof(long) and the signedness of a plain char, which CONTRIBUTING already keeps out of keys. The two compilers also stand behind their choice differently. GCC's manual documents it ↗: the value is reduced modulo 2^N to fit a type of width N, and no signal is raised. Clang has no such page; a request for one, llvm-project issue #11644, "document implementation-defined behavior" ↗, is still open. So what clang prints here is a measurement on one x86-64 Mac, not a promise, and the arm64 runner has not been asked.

Rust turns the same result into a definition: 300_i32 as i8 is 44 on every target, because as between two integer types keeps the low bits.

Two fixes

Both are at the end of signed_unsigned_c.c, and both print 1.

Convert both sides to a type that holds both ranges. (long long)minus_one < (long long)one compares -1 with 1 as long long, which holds every int and every unsigned int. That is what the second _Static_assert checks. The fix runs out at the top: no standard integer type is wider than unsigned long long, so a long long against an unsigned long long has no bigger type to go to.

Test the sign first. minus_one < 0 || (unsigned int)minus_one < one answers the part the conversion would get wrong before the conversion happens: a negative number is below every unsigned value. Only a non-negative int is converted, and converting one of those changes nothing. This works at any width. C++20 has the same test in its library as std::cmp_less. C has no such function, so you write the test out.

If you are coming from another language

Rust. The compiler makes you choose the conversion. b.into() is offered for u16 to i32 because every u16 fits; for i32 against u32 there is no lossless From in either direction, so you widen both to i64 or use try_from. The Rust library covers this in Comparing two number types ↗, and covers as, the cast that wraps the way C's conversion to unsigned does, in Casting with as ↗.

Python. An int has no width and no unsigned form, so -1 < 1 is True and there is no conversion to go wrong. Python also compares an int with a float by exact value rather than converting the int first: 2**53 + 1 == float(2**53 + 1) is False, where the C above prints 1 for the same comparison. See Comparing int and float ↗. C's wrap-around turns up only at the edge where Python talks to C: ctypes.c_uint(-1).value is 4294967295.

ABAP. (Not machine-checked — CI cannot run ABAP.) ABAP's integer types are signed (i, int8, and the internal s) apart from the one-byte b, and it has no unsigned 32-bit type, so the int-against-unsigned int pair does not exist. Assigning a value outside a target's range raises CX_SY_CONVERSION_OVERFLOW rather than wrapping, so there is no silent 44 either.

See also