Choosing Integer Types for C Public APIs: A Decision Guide
C library authors face a trade-off between standard types, exact-width C99 types, and implementation-optimized variants. This guide compares them across portability, ABI stability, and serialization needs, then shows how to validate layout assumptions with compiler checks and sanitizers.
02 Aug 2025, 22:16 UTC

The silent ABI break
When a C library changes a public struct field from long to int32_t, or vice versa, the source code may compile cleanly on one platform and fail silently on another. The root cause is that long is 32 bits on LLP64 systems such as 64-bit Windows, but 64 bits on LP64 systems such as Linux and macOS. A library compiled against one assumption and linked against code built with another can corrupt memory, misread file offsets, or crash inside a callback. The choice of integer type in a public API is therefore an ABI decision, not merely a style preference.
Decision constraints
Before selecting a type, define which constraints dominate:
- Portability: Will the code compile on freestanding embedded targets, DSPs, and hosted systems?
- ABI stability: Must struct layouts, function signatures, and serialized formats remain identical across compiler versions and data models?
- Performance: Does the type map to the most efficient register width for hot loops, or is exact width more important?
- Interoperability: Does the API exchange data with wire protocols, file formats, hardware registers, or other languages?
Comparing supported options
| Type family | Width guarantee | Best for | Risks |
|---|---|---|---|
int, long | Implementation-defined; long is 32-bit on LLP64, 64-bit on LP64 | General-purpose locals, loop counters where natural width is acceptable | Silent ABI mismatches in public structs; sign surprises |
int32_t, uint64_t | Exact bit width (when provided) | Wire protocols, file formats, memory-mapped registers, public API parameters | Optional in C99/C11; absent on some embedded toolchains; no layout guarantee for bit-fields |
int_least32_t, int_fast32_t | At least N bits; int_fast32_t may be 64 bits even when 32-bit exists | Internal computation where speed matters more than exact width | Unpredictable serialized layouts; poor for ABI boundaries |
size_t, ptrdiff_t | Matches object size and pointer-difference width | Buffer lengths, array indices, pointer subtraction | Not a substitute for protocol fields; unsigned wraparound in subtraction |
Trade-offs in practice
Exact-width types from <stdint.h> remove ambiguity about bit count, which makes them the safest choice for anything that crosses a serialization boundary. If a field represents a 32-bit checksum in a file header, uint32_t communicates that intent directly. Standard types such as long communicate only "some signed integer at least this large," which is insufficient when another implementation must read the same bytes.
The least and fast variants trade exactness for implementation freedom. int_fast32_t might occupy 64 bits on a platform where 64-bit arithmetic is faster than 32-bit arithmetic. That is useful for a local accumulator, but disastrous if the type appears in a public struct that is memcpy'd to disk or shared memory.
Even with exact-width types, C does not guarantee struct layout. Padding, alignment, and bit-field packing remain implementation-defined. Fixed-width types control element width, not total structure size.
A concrete header pattern
A portable public header can combine exact-width types with compile-time checks and fallbacks for freestanding environments:
/* public_api.h */
#ifndef PUBLIC_API_H
#define PUBLIC_API_H
/* Exact-width types are optional in C99/C11; provide fallbacks
only when the target lacks them. */
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L
# include <stdint.h>
#else
# include <limits.h>
# if !defined(INT32_MAX)
typedef int int32_t;
typedef unsigned int uint32_t;
# endif
# if !defined(INT64_MAX)
typedef long long int64_t;
typedef unsigned long long uint64_t;
# endif
#endif
#include <stddef.h>
/* Public structure: exact widths for serialized fields. */
struct packet_header {
uint32_t magic; /* protocol identifier */
uint16_t version; /* format version */
uint16_t flags; /* bit flags */
uint64_t timestamp; /* nanoseconds since epoch */
uint32_t payload_len; /* size_t is not used: wire format is fixed */
};
/* Object sizes remain size_t. */
size_t api_decode(const unsigned char *buf, size_t buf_len,
struct packet_header *out);
/* Compile-time validation of assumptions. */
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
_Static_assert(sizeof(uint32_t) == 4, "uint32_t must be 4 bytes");
_Static_assert(sizeof(uint64_t) == 8, "uint64_t must be 8 bytes");
_Static_assert(sizeof(struct packet_header) == 16,
"unexpected padding in packet_header");
#endif
#endif /* PUBLIC_API_H */
Run these checks on the build host with your normal user permissions; no elevated privileges are required for compilation. For a hosted GCC or Clang build, compile with warnings that expose implicit narrowing and sign changes:
gcc -std=c11 -Wall -Wextra -Wconversion -c public_api.c -o public_api.o
Expected check: the compiler should emit no -Wconversion warnings for assignments between the fixed-width fields and equally sized temporaries. Warnings here indicate a sign or width mismatch that could corrupt a serialized field.
To catch runtime type-boundary errors, build a test harness with integer UndefinedBehaviorSanitizer:
gcc -std=c11 -fsanitize=integer -g test_decode.c public_api.c -o test_decode
./test_decode
Expected check: the sanitizer reports signed overflow, division by zero, or invalid shifts if the decoder mishandles values near UINT32_MAX or INT64_MIN. This is particularly important when mixing signed and unsigned fixed-width types in arithmetic; cast to a common unsigned type before bitwise operations to avoid promotion surprises.
For ABI verification across data models, cross-compile to a 32-bit target and inspect the resulting layout:
gcc -std=c11 -m32 -S public_api.c -o public_api_32.s
Expected check: the generated assembly and offsetof results for struct packet_header should match the 64-bit build. If payload_len shifts because an internal long was used instead of uint32_t, the ABI is not stable.
Limitations and practical verification
Fixed-width types do not solve every problem. They are optional in C99 and C11; some embedded and DSP toolchains omit <stdint.h> entirely or provide only a subset. Always verify availability before committing to them in a public header, and keep fallback typedefs guarded so they do not conflict with a complete standard library.
Bit-fields remain implementation-defined even when declared with fixed-width types. Signedness, alignment, and packing can differ between compilers, so do not assume that uint32_t flags : 4; serializes identically everywhere.
C99 guarantees long long is at least 64 bits, but its alignment and ABI treatment still varies by compiler and platform, so it is not a universal substitute for int64_t. C23 mandates two's complement representation and introduces <stdbit.h>, yet C99 and C11 fixed-width types remain the portable baseline for most existing toolchains.
Finally, char has implementation-defined signedness. If a public API accepts 8-bit signed values, use int8_t explicitly rather than char.
A practical way to check the result is to add a continuous-integration job that compiles the public header with -Wall -Wextra -Wconversion on every supported target, runs the _Static_assert suite, and executes the UndefinedBehaviorSanitizer build. If that job passes on both LP64 and LLP64 toolchains, the integer-type choices in the public API are likely stable across those data models.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.