C uses two's complement for all signed integer types (mandated by C23; universally true in practice before that). 1 The exact same bit pattern is placed on the wire regardless of sign — the difference is only in interpretation:
| C type | Bits | Wire byte 0xFF |
Wire byte 0x01 |
|---|---|---|---|
uint8_t |
8 | 255 | 1 |
int8_t |
8 | −1 | 1 |
uint16_t |
16 | — | — |
int16_t |
16 | — | — |
The most significant bit (MSB) is the sign bit: 0 → non-negative, 1 → negative for signed types. 2
Endianness in C: Multi-byte integers are host-endian by default. Network code typically calls htons()/htonl() to convert to big-endian (network byte order) before sending, and ntohs()/ntohl() on receipt. You must know what the C sender does.
Elixir integers are arbitrary precision — there is no signed/unsigned overflow concept at the language level. The signed/unsigned distinction only comes into play when reading or writing binary data using the << >> bitstring syntax.
The <<segment::modifiers>> syntax supports 9 types and several modifiers. 3
integer, float, binary (alias bytes), bitstring (alias bits), utf8, utf16, utf32
| Modifier | Applies to | Default? |
|---|---|---|
unsigned |
integer |
✅ Yes |
signed |
integer |
❌ No |
big |
integer, float, utf16, utf32 |
✅ Yes |
little |
integer, float, utf16, utf32 |
❌ No |
native |
integer, float, utf16, utf32 |
❌ No (VM-determined) |
size(N) |
all | — |
unit(N) |
all | 1 bit for integer/float; 8 bits for binary |
Default integer size is 8 bits, default endianness is big. 3
signed/unsigned only affect how bits are interpreted when pattern matching (decoding). The underlying bytes are identical in both cases — it's purely a reinterpretation: 3
# The byte 0x9C (156 unsigned = -100 signed as two's complement)
# Decoded as unsigned (default):
<<int::integer>> = <<0x9C>>
# int => 156
# Decoded as signed:
<<int::integer-signed>> = <<0x9C>>
# int => -100When constructing a binary (e.g., to send to C), negative values are accepted and stored as two's complement automatically:
<<-100::signed-integer-8>> # => <<156>> (same byte 0x9C)
<<156::unsigned-integer-8>> # => <<156>> (same byte 0x9C)When your C code sends a struct over a TCP socket, Elixir receives it as a binary. You use pattern matching to decode each field, annotating exactly what C used:
Suppose C sends:
struct Msg {
uint8_t type; // 1 byte, unsigned
int16_t delta; // 2 bytes, signed, little-endian
uint32_t timestamp; // 4 bytes, unsigned, big-endian (network order)
};In Elixir:
def decode_msg(<<
type :: unsigned-integer-8,
delta :: signed-little-integer-16,
ts :: unsigned-big-integer-32,
rest :: binary
>>) do
%{type: type, delta: delta, timestamp: ts, rest: rest}
endNote:
bigis the default, sounsigned-big-integer-32andunsigned-integer-32are equivalent. Using explicit modifiers makes intent clear, especially in mixed-endian protocols.
A common C pattern is to prefix a payload with its length:
def decode_payload(<<
length :: unsigned-integer-16,
payload :: binary-size(length),
rest :: binary
>>) do
{payload, rest}
end# Build a response: unsigned 8-bit type, signed 16-bit value (little-endian)
def encode_msg(type, delta) do
<<type :: unsigned-integer-8, delta :: signed-little-integer-16>>
end
encode_msg(3, -500)
# => <<3, 12, 254>> (3 = type; -500 little-endian = <<12, 254>>)If you've received an integer and need to reinterpret its signedness (e.g., the upstream code already matched as unsigned and you now need the signed value):
# Re-interpret an unsigned 8-bit integer as signed
def unsigned_to_signed8(n) when n > 127, do: n - 256
def unsigned_to_signed8(n), do: n
# Or more elegantly via binary round-trip:
def reinterpret_as_signed16(unsigned_val) do
<<signed::signed-integer-16>> = <<unsigned_val::unsigned-integer-16>>
signed
end
# Inverse: signed → unsigned
def reinterpret_as_unsigned8(signed_val) do
<<unsigned::unsigned-integer-8>> = <<signed_val::signed-integer-8>>
unsigned
endThe binary round-trip is the idiomatic Elixir way — you write the value in one form and re-read it with the desired signedness modifier. No manual bit twiddling needed.
# Big-endian (network byte order, Elixir default)
<<value::big-integer-size(16)>> = <<0x01, 0x00>>
# value = 256
# Little-endian (x86 native, many embedded systems)
<<value::little-integer-size(16)>> = <<0x01, 0x00>>
# value = 1
# native: whatever the BEAM VM host CPU uses (determined at startup)
<<value::native-integer-size(16)>> = <<0x01, 0x00>>
⚠️ Prefer explicitbig/littleovernativein network protocols —nativedepends on the Elixir host platform and will break portability.
The credo-binary-patterns library enforces consistency in binary pattern syntax. The enforced ordering is: 5
<<x :: [endian]-[sign]-[type]-[size]>>
For example:
# ✅ Correct ordering:
<<x::little-signed-integer-size(16)>>
# ✅ Also valid shorthand:
<<x::little-signed-integer-16>>It also flags unnecessary defaults: 5
# ❌ Flagged: integer defaults to 8 bits
<<x::integer-8>>
# ❌ Flagged: integer defaults to big-endian
<<x::big-integer>>
# ✅ Correct minimal form:
<<x::integer>>bytes vs binary sizing convention: 5
# ✅ Use bytes with bare size:
<<x::16-bytes>>
# ✅ Use binary with size():
<<x::binary-size(16)>>
# ❌ Don't mix:
<<x::binary-16>> # bare size with binary type| Pitfall | Issue | Fix |
|---|---|---|
Default unsigned matching |
A C int8_t of −1 (0xFF) reads as 255 in Elixir |
Add signed modifier: <<x::signed-integer-8>> |
Default big endian |
C sends little-endian (int16_t on x86) but Elixir reads as big |
Add little modifier: <<x::little-integer-16>> |
Confusing signed for construction |
signed/unsigned have no effect when building a binary from an integer literal |
Use them for pattern matching, not construction |
Using native endianness |
Breaks if C and Elixir run on different-endian hosts | Be explicit with big or little |
| Missing size on non-last binary segment | All but the last binary field must have size(N) |
<<a::binary-size(4), rest::binary>> |
References