C++ Module 2 – Types, values and expressions
C++ fundamental types: integers, floats, char, bool
What each C++ fundamental type holds, why long is 8 bytes on macOS and Linux but 4 on Windows, and how to pick exact widths with <cstdint>.
What you will learn
- Choose an integer type by the range of values it must hold
- Use the fixed-width types from <cstdint> when sizes matter
- Query sizes and limits with sizeof and std::numeric_limits
Before you start
What you’ll learn · Before you start
What you will learn
- Choose an integer type by the range of values it must hold
- Use the fixed-width types from <cstdint> when sizes matter
- Query sizes and limits with sizeof and std::numeric_limits
Before you start
On this page
The fundamental types are the ones built into the C++ language: the integers (short, int, long and
long long, each signed or unsigned), the floating-point types (float, double and long double), the character
types (char and its relatives), bool, and void. The standard does not fix their sizes. It fixes a minimum width
for each integer type and the order between them, and the rest comes from the platform. On a 64-bit Mac or Linux
machine an int is 4 bytes and a long is 8, while on 64-bit Windows a long is only 4. Even the sign of plain
char differs between platforms. So choose a type by the range of values it must hold, ask the compiler with
sizeof and std::numeric_limits when you need the facts, and use the exact-width types of <cstdint>, such as
std::int64_t, wherever a size is part of the contract.
The types and their sizes
sizeof(T) is the size of a type in bytes, where a byte is a char, 8 bits on every platform you are likely to
use. This program prints it for every fundamental type:
#include <cstddef>
#include <print>
template <class T>
void show(const char* name) {
std::println("{:<12} {:>2} bytes {:>3} bits", name, sizeof(T), 8 * sizeof(T));
}
int main() {
show<bool>("bool");
show<char>("char");
show<short>("short");
show<int>("int");
show<long>("long");
show<long long>("long long");
show<float>("float");
show<double>("double");
show<long double>("long double");
show<wchar_t>("wchar_t");
show<char8_t>("char8_t");
show<char16_t>("char16_t");
show<char32_t>("char32_t");
show<void*>("void*");
show<std::size_t>("std::size_t");
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra sizes.cpp -o sizes
./sizes
g++-16 -std=c++23 -Wall -Wextra sizes.cpp -o sizes
./sizes
""") Output
$ clang++ -std=c++23 -Wall -Wextra sizes.cpp -o sizes $ ./sizes bool 1 bytes 8 bits char 1 bytes 8 bits short 2 bytes 16 bits int 4 bytes 32 bits long 8 bytes 64 bits long long 8 bytes 64 bits float 4 bytes 32 bits double 8 bytes 64 bits long double 8 bytes 64 bits wchar_t 4 bytes 32 bits char8_t 1 bytes 8 bits char16_t 2 bytes 16 bits char32_t 4 bytes 32 bits void* 8 bytes 64 bits std::size_t 8 bytes 64 bits $ g++-16 -std=c++23 -Wall -Wextra sizes.cpp -o sizes $ ./sizes bool 1 bytes 8 bits char 1 bytes 8 bits short 2 bytes 16 bits int 4 bytes 32 bits long 8 bytes 64 bits long long 8 bytes 64 bits float 4 bytes 32 bits double 8 bytes 64 bits long double 8 bytes 64 bits wchar_t 4 bytes 32 bits char8_t 1 bytes 8 bits char16_t 2 bytes 16 bits char32_t 4 bytes 32 bits void* 8 bytes 64 bits std::size_t 8 bytes 64 bits
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizes_session.py
As in every session of this track, the lines that start with $ are commands, and the rest is what they printed.
Both compilers agree, because the sizes belong to the platform rather than to the compiler: the operating system’s
data model and the processor’s calling convention decide them, and every compiler for that platform follows. 64-bit
macOS and Linux use a data model called LP64, in which long and pointers are 64 bits. 64-bit Windows uses
LLP64, in which only long long and pointers are, and Microsoft’s documentation lists long as 4 bytes there.
Sizes of the integer types on 64-bit systems
Text description of the diagram
A bar chart with two bars per type, one for 64-bit macOS and Linux (the LP64 data model) and one for 64-bit Windows (the LLP64 data model). Each bar is labelled with its size in bytes.
- short: 2 bytes on both.
- int: 4 bytes on both.
- long: 8 bytes on macOS and Linux, 4 bytes on Windows.
- long long: 8 bytes on both.
- void*, a pointer: 8 bytes on both.
- wchar_t: 4 bytes on macOS and Linux, 2 bytes on Windows.
The standard itself promises much less. Since C++20 it sets these minimum widths, and requires each type to be at least as wide as the one before it. The Mac column comes from the run above, the Windows column from Microsoft’s documentation:
| Type | Standard minimum | 64-bit Mac | 64-bit Windows |
|---|---|---|---|
signed char |
8 bits | 1 byte | 1 byte |
short |
16 bits | 2 bytes | 2 bytes |
int |
16 bits | 4 bytes | 4 bytes |
long |
32 bits | 8 bytes | 4 bytes |
long long |
64 bits | 8 bytes | 8 bytes |
An int of 16 bits is allowed, and some small embedded processors have one. The safe reading of the table is that
int holds at least -32,768 to 32,767, long at least about plus or minus 2.1 billion, and long long at least
about plus or minus 9.2 × 10¹⁸. Anything more is a property of your platform.
Ranges with std::numeric_limits
<limits> describes each arithmetic type: its lowest and largest values, how many bits or decimal digits it keeps,
whether it is signed. It works at compile time, so it costs nothing at run time:
#include <limits>
#include <print>
template <class T>
void row(const char* name) {
using limits = std::numeric_limits<T>;
std::println("{:<18} {:>26} {:>26}", name, limits::lowest(), limits::max());
}
int main() {
std::println("{:<18} {:>26} {:>26}", "type", "lowest", "max");
row<signed char>("signed char");
row<unsigned char>("unsigned char");
row<short>("short");
row<int>("int");
row<unsigned>("unsigned");
row<long long>("long long");
row<unsigned long long>("unsigned long long");
row<float>("float");
row<double>("double");
std::println("");
std::println("decimal digits a float keeps: {}, a double: {}", std::numeric_limits<float>::digits10,
std::numeric_limits<double>::digits10);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra limits.cpp -o limits
./limits
g++-16 -std=c++23 -Wall -Wextra limits.cpp -o limits
./limits
""") Output
$ clang++ -std=c++23 -Wall -Wextra limits.cpp -o limits $ ./limits type lowest max signed char -128 127 unsigned char 0 255 short -32768 32767 int -2147483648 2147483647 unsigned 0 4294967295 long long -9223372036854775808 9223372036854775807 unsigned long long 0 18446744073709551615 float -3.4028235e+38 3.4028235e+38 double -1.7976931348623157e+308 1.7976931348623157e+308 decimal digits a float keeps: 6, a double: 15 $ g++-16 -std=c++23 -Wall -Wextra limits.cpp -o limits $ ./limits type lowest max signed char -128 127 unsigned char 0 255 short -32768 32767 int -2147483648 2147483647 unsigned 0 4294967295 long long -9223372036854775808 9223372036854775807 unsigned long long 0 18446744073709551615 float -3.4028235e+38 3.4028235e+38 double -1.7976931348623157e+308 1.7976931348623157e+308 decimal digits a float keeps: 6, a double: 15
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 limits_session.py
Every signed integer runs from -2ᴺ⁻¹ to 2ᴺ⁻¹ - 1 for its N bits, and every unsigned one from 0 to 2ᴺ - 1. That is
guaranteed since C++20, which made two’s complement the only allowed representation of signed integers; earlier
standards also allowed two others that mainstream processors no longer use. What two’s complement did not change is overflow:
going past max() with a signed type is still undefined behaviour, the subject of the next lesson.
Two details of the output are worth a second look. For floating-point types, lowest() is the most negative value,
while min() means something else: the smallest positive normal number, about 1.2 × 10⁻³⁸ for a float. And
digits10 says how many decimal digits a type keeps without fail: 6 for float and 15 for double. The
floating-point lesson takes these apart.
Exact widths with <cstdint>
When a size is part of a contract, such as a file format, a network protocol or an interface to hardware, use the
fixed-width types of <cstdint>: std::int8_t, std::int16_t, std::int32_t and std::int64_t, and the unsigned
std::uint8_t to std::uint64_t. Each has exactly the width in its name, with no padding bits.
std::size_t, the type sizeof returns and the standard containers use for sizes, is unsigned, and on mainstream
platforms as wide as a pointer: 8 bytes here.
fixed_width.cpp
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <print>
#include <type_traits>
int main() {
std::int32_t ms_per_day{86'400'000}; // fits: at most 2,147,483,647
std::int64_t ms_per_year{31'536'000'000}; // needs more than 32 bits
std::uint8_t grade{66}; // one byte, 0 to 255
std::println("{} ms a day, {} ms a year", ms_per_day, ms_per_year);
// std::uint8_t is a character type underneath, and streams print characters:
std::cout << "cout prints grade as " << grade << '\n';
std::println("println prints grade as {}", grade);
static_assert(sizeof(std::int64_t) == 8);
static_assert(std::is_unsigned_v<std::size_t>);
static_assert(std::is_same_v<std::uint8_t, unsigned char>);
} choose_types.cpp
#include <cstdint>
#include <print>
#include <utility>
// The smallest fixed-width signed type that can hold value.
constexpr const char* smallest_signed(long long value) {
if (std::in_range<std::int8_t>(value)) return "std::int8_t";
if (std::in_range<std::int16_t>(value)) return "std::int16_t";
if (std::in_range<std::int32_t>(value)) return "std::int32_t";
return "std::int64_t";
}
int main() {
for (long long value : {100LL, -200LL, 40'000LL, 86'400'000LL, 3'000'000'000LL, 31'536'000'000LL}) {
std::println("{:>14} needs {}", value, smallest_signed(value));
}
// The same checks while the program compiles: a wrong choice stops the build.
static_assert(std::in_range<std::int16_t>(-200));
static_assert(!std::in_range<std::int16_t>(40'000));
static_assert(std::in_range<std::uint16_t>(40'000));
static_assert(!std::in_range<std::int32_t>(3'000'000'000LL));
static_assert(std::in_range<std::uint32_t>(3'000'000'000LL));
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra fixed_width.cpp -o fixed_width
./fixed_width
clang++ -std=c++23 -Wall -Wextra choose_types.cpp -o choose_types
./choose_types
g++-16 -std=c++23 -Wall -Wextra fixed_width.cpp -o fixed_width
./fixed_width
g++-16 -std=c++23 -Wall -Wextra choose_types.cpp -o choose_types
./choose_types
""") Output
$ clang++ -std=c++23 -Wall -Wextra fixed_width.cpp -o fixed_width
$ ./fixed_width
86400000 ms a day, 31536000000 ms a year
cout prints grade as B
println prints grade as 66
$ clang++ -std=c++23 -Wall -Wextra choose_types.cpp -o choose_types
$ ./choose_types
100 needs std::int8_t
-200 needs std::int16_t
40000 needs std::int32_t
86400000 needs std::int32_t
3000000000 needs std::int64_t
31536000000 needs std::int64_t
$ g++-16 -std=c++23 -Wall -Wextra fixed_width.cpp -o fixed_width
$ ./fixed_width
86400000 ms a day, 31536000000 ms a year
cout prints grade as B
println prints grade as 66
$ g++-16 -std=c++23 -Wall -Wextra choose_types.cpp -o choose_types
$ ./choose_types
100 needs std::int8_t
-200 needs std::int16_t
40000 needs std::int32_t
86400000 needs std::int32_t
3000000000 needs std::int64_t
31536000000 needs std::int64_t
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fixed_width_session.py
The first file shows the classic surprise of the 8-bit types. On these platforms std::uint8_t is another name for
unsigned char, and std::cout prints a char type as a character, so the grade 66 comes out as the letter B.
std::println formats signed char and unsigned char as numbers, which is one more reason to prefer it. With
std::cout, write +grade: the unary plus promotes the value to int.
The second file answers the question behind every choice of type, “which type holds this value?”, with
std::in_range from <utility> (C++20). It is true when a value fits the type given in angle brackets, whatever
the signs of the two types, and it works at compile time, so the static_assert lines at the end turn a wrong choice
into a build error. 86,400,000 milliseconds, one day, needs 32 bits; 31,536,000,000, one year of 365 days, needs
64. The numbers are ordinary: a counter of milliseconds or of bytes outgrows std::int32_t sooner than people expect.
For everyday work these rules are enough:
intfor counts and indexes that stay well below 2 billion, which is what most code does.long longorstd::int64_tfor anything that can reach 2 billion: file sizes, money in the smallest unit, timestamps in milliseconds. Avoid plainlongin portable code, because its size depends on the platform.- The fixed-width types for data that leaves the program, so that every machine reads the same bytes.
- Signed types for arithmetic, as rule ES.102 of the C++ Core Guidelines advises.
std::size_tappears whenever you ask a container for its size, and mixing it with signed values has traps of its own, covered in the next lesson.
Three character types, not two
char, signed char and unsigned char are three distinct types, even though plain char behaves exactly like one
of the other two. Which one is up to the platform:
#include <climits>
#include <print>
#include <type_traits>
int main() {
char byte = static_cast<char>(0xC8); // the bit pattern 11001000
std::println("plain char is signed: {}", std::is_signed_v<char>);
std::println("range of char: {} to {}", CHAR_MIN, CHAR_MAX);
std::println("the byte 0xC8 as an int: {}", static_cast<int>(byte));
std::println("char is the same type as signed char: {}", std::is_same_v<char, signed char>);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra char_sign.cpp -o char_sign
./char_sign
g++-16 -std=c++23 -Wall -Wextra char_sign.cpp -o char_sign
./char_sign
# The same file as compilers build it for Linux on arm64, where plain char is unsigned:
g++-16 -std=c++23 -Wall -Wextra -funsigned-char char_sign.cpp -o char_sign
./char_sign
""") Output
$ clang++ -std=c++23 -Wall -Wextra char_sign.cpp -o char_sign $ ./char_sign plain char is signed: true range of char: -128 to 127 the byte 0xC8 as an int: -56 char is the same type as signed char: false $ g++-16 -std=c++23 -Wall -Wextra char_sign.cpp -o char_sign $ ./char_sign plain char is signed: true range of char: -128 to 127 the byte 0xC8 as an int: -56 char is the same type as signed char: false # The same file as compilers build it for Linux on arm64, where plain char is unsigned: $ g++-16 -std=c++23 -Wall -Wextra -funsigned-char char_sign.cpp -o char_sign $ ./char_sign plain char is signed: false range of char: 0 to 255 the byte 0xC8 as an int: 200 char is the same type as signed char: false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 char_sign_session.py
On Apple silicon, plain char is signed: Apple’s documentation lists it as one of the places where Apple’s 64-bit
ABI differs from Arm’s standard one. The byte 0xC8 therefore reads as -56. Arm’s own procedure call standard,
AAPCS64, which Linux on arm64 follows, makes plain char unsigned. GCC’s -funsigned-char builds the same file that
way, and the byte reads as 200. On Windows, Microsoft’s compiler makes char signed unless you pass /J. Code that
works on raw bytes, such as a checksum or a file parser, should therefore say what it means: unsigned char,
std::uint8_t or std::byte, never plain char. A whole program, its libraries included, must be built with one
setting, so treat -funsigned-char as a way to test, not as a fix.
The other character types hold units of text encodings. char8_t, char16_t and char32_t hold UTF-8, UTF-16 and
UTF-32 code units, at 1, 2 and 4 bytes everywhere, as the sizes session showed. wchar_t is the odd one: 4 bytes on
macOS and Linux, 2 bytes on Windows, so it holds different encodings on different systems. A later lesson of this
module is about text encodings.
bool
bool holds true or false and takes 1 byte on these platforms. It converts both ways with numbers:
#include <iostream>
#include <print>
int main() {
bool from_negative = -3; // any value other than zero converts to true
bool from_zero = 0.0; // zero converts to false
int from_true = true; // true converts to 1, false to 0
std::println("{} {} {}", from_negative, from_zero, from_true);
std::println("true + true = {}", true + true);
std::cout << "cout prints true as " << true << ", with std::boolalpha as " << std::boolalpha << true << '\n';
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra bools.cpp -o bools
./bools
g++-16 -std=c++23 -Wall -Wextra bools.cpp -o bools
./bools
""") Output
$ clang++ -std=c++23 -Wall -Wextra bools.cpp -o bools $ ./bools true false 1 true + true = 2 cout prints true as 1, with std::boolalpha as true $ g++-16 -std=c++23 -Wall -Wextra bools.cpp -o bools $ ./bools true false 1 true + true = 2 cout prints true as 1, with std::boolalpha as true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bools_session.py
Any number other than zero converts to true, and zero to false; true converts to 1 and false to 0. In
arithmetic a bool is promoted to int first, so true + true is 2. std::cout prints a bool as 1 or 0 unless
you set std::boolalpha, while std::println prints true and false.
Floating-point types in brief
float and double are the IEEE 754 binary formats of 32 and 64 bits on every mainstream platform: a float keeps
6 decimal digits without fail and a double 15, as the limits session printed. Prefer double unless memory is
tight, as in large arrays or on graphics hardware. long double is the least portable fundamental type. On Apple
silicon it is identical to double, 8 bytes, which Apple documents as another difference from the standard Arm ABI,
where it is a 16-byte quad-precision type. The next lessons cover what floating-point numbers can and cannot store
exactly.
Interview questions
Fresher · Is an int always 32 bits in C++?
No. The standard only requires an int to be at least 16 bits wide, and no narrower than short. It is 32 bits on
all mainstream 32-bit and 64-bit desktop, server and phone platforms, which is why people assume it, but small
embedded processors may use 16. The type that really differs between today’s platforms is long: 8 bytes on 64-bit
Linux and macOS, 4 bytes on 64-bit Windows. When a program needs an exact width, it should say so with
std::int32_t or std::int64_t from <cstdint>, and it can check an assumption at compile time with
static_assert(sizeof(int) == 4);.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Key takeaways
- The standard fixes minimum widths (
shortandint16 bits,long32,long long64), not sizes; the platform decides the rest. - 64-bit macOS and Linux are LP64 (
longis 8 bytes); 64-bit Windows is LLP64 (longis 4 bytes). Avoid plainlongin portable code. sizeofgives sizes in bytes, andstd::numeric_limits<T>giveslowest(),max(),digits10and more, at compile time.- Use
std::int32_t,std::int64_tand the other<cstdint>types where the width matters, andstd::in_rangeto check that a value fits. std::uint8_tis a character type underneath:std::coutprints it as a character,std::printlnas a number.- Plain
charis signed on Apple silicon and Windows and unsigned on Linux for arm64. Useunsigned charorstd::bytefor bytes. - Non-zero converts to
true,trueto 1;long doubleis just adoubleon Apple silicon.
References
- Working Draft, Programming Languages: C++ (N4950): [basic.fundamental] Fundamental types (ISO/IEC JTC1/SC22/WG21)
- P1236R1: Alternative Wording for P0907R4 Signed Integers are Two's Complement (ISO/IEC JTC1/SC22/WG21)
- Data Type Ranges (Microsoft C++) (Microsoft)
- Writing ARM64 code for Apple platforms (Apple)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): Arm C and C++ language mappings (Arm)
- Options Controlling C Dialect (-fsigned-char, -funsigned-char) (GNU Project)
- C++ Core Guidelines: ES.102, Use signed types for arithmetic (Standard C++ Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress