C23 new features: nullptr, constexpr, #embed by example
nullptr, constexpr, typeof, auto, #embed, ckd_add, <stdbit.h>, %b: each C23 feature with an example compiled by GCC 15 and the version that first supports it.
Since GCC 15, the compiler applies the C23 standard by default, with no option at all. Part of what it changes breaks old programs; that's the subject of GCC 15 defaults to C23. The other part is the pleasant one, things C programmers have wanted for a long time: a real null pointer, real constants, type inference, checked integer arithmetic. Each one is shown below with a program compiled by GCC 15.3 and what it prints.
In short
C23 is the version of the C language that ISO published at the end of 2024. It adds nullptr, makes bool, true and false keywords, introduces constexpr, typeof and auto for variables, attributes such as [[nodiscard]] and [[deprecated]], binary literals like 0b1010 with the ' digit separator, the #embed directive, checked arithmetic in <stdckdint.h> and the bit functions of <stdbit.h>. With GCC 15, everything that depends on the compiler is on by default; GCC 13 and 14 support much of it with -std=c2x or -std=c23, and a few additions depend on the version of your C library.
Which version of C does your compiler apply?
The __STDC_VERSION__ macro tells you. It equals 202311L in C23.
#include <stdio.h>
int main(void){ printf("%ld\n", __STDC_VERSION__); return 0;}$ gcc --version | head -n 1gcc (GCC) 15.3.0$ gcc version.c -o version && ./version202311With no option, GCC 15 compiles as -std=gnu23, which is C23 plus the GNU extensions; -std=c23 turns off the extensions that conflict with the standard. Unless stated otherwise, the examples below are compiled with no -std option.
nullptr, bool, true and false become keywords
nullptr is a null pointer constant with a type of its own, nullptr_t (defined in <stddef.h>). The difference from NULL is that NULL is a macro whose definition is up to the implementation: depending on the system, it can be the integer 0 or ((void *)0). _Generic, which picks a branch based on the type of an expression, makes this visible:
#include <stddef.h>#include <stdio.h>
#define TYPE(x) _Generic((x), nullptr_t: "nullptr_t", void *: "void *", \ bool: "bool", int: "int", default: "other")
int main(void){ int *p = nullptr;
printf("NULL: %s\n", TYPE(NULL)); printf("nullptr: %s\n", TYPE(nullptr)); printf("true: %s\n", TYPE(true)); printf("p == nullptr: %d\n", p == nullptr); return 0;}$ gcc -Wall -Wextra nullptr.c -o nullptr && ./nullptrNULL: void *nullptr: nullptr_ttrue: boolp == nullptr: 1With glibc, NULL is a void *; nullptr has the same type everywhere. Note the other two lines as well: bool works without #include <stdbool.h>, and true has type bool instead of being an int equal to 1. Since bool is now a keyword, an old typedef int bool; no longer compiles.
constexpr: real compile-time constants
A const variable in C isn't a constant as far as the compiler is concerned: you can't use it as the size of a global array.
$ gcc -c const-size.cconst-size.c:2:5: error: variably modified 'scores' at file scope 2 | int scores[SIZE]; | ^~~~~~const-size.c holds nothing but const int SIZE = 4; followed by that line. constexpr declares an object whose value is computed and checked at compile time, and which you can use anywhere a constant is expected.
#include <stdio.h>
constexpr int SIZE = 4;constexpr double VAT = 0.2;
int scores[SIZE];
int main(void){ printf("%zu elements, VAT %.0f%%\n", sizeof scores / sizeof scores[0], VAT * 100); return 0;}$ gcc -Wall -Wextra constexpr.c -o constexpr && ./constexpr4 elements, VAT 20%The compiler also checks that the value fits the type exactly. An unsigned char can't hold 300: with const, GCC would truncate it to 44 and only warn; with constexpr, it's an error.
$ gcc -c constexpr-bad.cconstexpr-bad.c:1:32: warning: unsigned conversion from 'int' to 'unsigned char' changes value from '300' to '44' [-Woverflow] 1 | constexpr unsigned char BYTE = 300; | ^~~constexpr-bad.c:1:32: error: 'constexpr' initializer not representable in type of objectIn C23, constexpr applies only to objects, not to functions as in C++, and not to pointers other than null ones. For an integer constant it's a better choice than a #define: it has a type and a scope, and the debugger knows about it.
typeof and auto: let the compiler work out the type
typeof(expr) gives you the type of an expression. GCC has offered it as an extension for a long time; C23 standardizes it, along with typeof_unqual, which gives the same type without its qualifiers (const, volatile...). The classic use is a macro that works for any type. auto, reused with a new meaning, deduces a variable's type from its initial value.
#include <stdio.h>
#define TYPE(x) _Generic((x), int: "int", double: "double", \ char *: "char *", default: "other")#define SWAP(a, b) do { typeof(a) tmp_ = (a); (a) = (b); (b) = tmp_; } while (0)
int main(void){ auto n = 42; auto x = 3.5; auto s = "text";
printf("n: %s, x: %s, s: %s\n", TYPE(n), TYPE(x), TYPE(s));
double a = 1.5, b = 2.5; SWAP(a, b); printf("a = %.1f, b = %.1f\n", a, b); return 0;}$ gcc -Wall -Wextra auto.c -o auto && ./auton: int, x: double, s: char *a = 2.5, b = 1.5auto requires an initializer and a single name per declaration, and the array "text" becomes a char * pointer. It doesn't apply to return types or to function parameters. Keep it for types that are long to write: auto n = 42; tells the reader nothing that int n = 42; didn't already.
Attributes: [[nodiscard]], [[maybe_unused]], [[deprecated]]
C23 adopts C++'s [[...]] attribute syntax. Three attributes earn their keep every day: [[nodiscard]] asks for a warning when a return value is ignored, [[deprecated]] marks a function that shouldn't be used anymore, and [[maybe_unused]] silences the warning for a parameter or variable left unused on purpose.
#include <stdlib.h>
[[nodiscard]] int *make_array(size_t n){ return malloc(n * sizeof(int));}
[[deprecated("use make_array")]] int *old_array(size_t n){ return calloc(n, sizeof(int));}
int main(int argc, [[maybe_unused]] char **argv){ int ignored = argc;
make_array(10); free(old_array(10)); return 0;}$ gcc -Wall -Wextra attributes.c -o attributesattributes.c: In function 'main':attributes.c:17:5: warning: ignoring return value of 'make_array', declared with attribute 'nodiscard' [-Wunused-result] 17 | make_array(10); | ^~~~~~~~~~~~~~attributes.c:3:20: note: declared here 3 | [[nodiscard]] int *make_array(size_t n) | ^~~~~~~~~~attributes.c:18:5: warning: 'old_array' is deprecated: use make_array [-Wdeprecated-declarations] 18 | free(old_array(10)); | ^~~~attributes.c:8:39: note: declared here 8 | [[deprecated("use make_array")]] int *old_array(size_t n) | ^~~~~~~~~attributes.c:15:9: warning: unused variable 'ignored' [-Wunused-variable] 15 | int ignored = argc; | ^~~~~~~[[nodiscard]] caught a real leak: the array allocated on line 17 is never freed, since its address is thrown away. argv isn't reported thanks to [[maybe_unused]], while ignored, which has no attribute, is.
Binary literals, the ' separator and %b
Integers can be written in binary with the 0b prefix, a GCC extension that C23 makes standard. The apostrophe separates digits for readability, in any base. On the library side, printf gains the %b conversion, which prints in binary, and %#b, which adds the prefix.
#include <stdio.h>
int main(void){ unsigned mask = 0b1010'0101; long million = 1'000'000;
printf("%u %b %#b\n", mask, mask, mask); printf("%ld\n", million); return 0;}$ gcc -Wall -Wextra binary.c -o binary && ./binary165 10100101 0b101001011000000%b depends on your C library, not on the compiler: glibc supports it since version 2.35. The other conversions are covered in the printf(3) entry.
#embed: put a file into an array
#embed reads a file at compile time and replaces itself with the file's bytes, separated by commas. You no longer need an external tool to turn an image or a text file into a C array. GCC supports it since version 15.
#include <stdio.h>
static const char message[] = {#embed "message.txt" , '\0'};
int main(void){ printf("%zu bytes: %s", sizeof message - 1, message); return 0;}$ gcc -Wall -Wextra embed.c -o embed && ./embed12 bytes: Hello, C23!message.txt contains "Hello, C23!" followed by a newline, 12 bytes in all. The file isn't a string: you have to add the terminating '\0' yourself, hence the comma after the directive. The directive takes parameters, such as limit(n) to read only the first n bytes.
ckd_add: detect overflow without undefined behavior
Overflowing a signed int is undefined behavior. Computing a + b and then checking whether the result "looks wrong" doesn't work: the compiler is allowed to delete your check. <stdckdint.h> provides ckd_add, ckd_sub and ckd_mul, which compute the result as if integers had no limit, store it in the variable whose address you pass, and return true if it doesn't fit.
#include <limits.h>#include <stdckdint.h>#include <stdio.h>
int main(void){ int sum;
if (ckd_add(&sum, INT_MAX - 1, 1)) printf("overflow\n"); else printf("ok: %d\n", sum);
if (ckd_add(&sum, INT_MAX, 1)) printf("overflow, sum is %d\n", sum);
size_t bytes; if (ckd_mul(&bytes, (size_t)1 << 62, sizeof(int))) printf("size too large for a size_t\n"); return 0;}$ gcc -Wall -Wextra ckd.c -o ckd && ./ckdok: 2147483647overflow, sum is -2147483648size too large for a size_tOn overflow, the variable receives the result wrapped to its width (INT_MIN here), and the return value is what you go by. The ckd_mul on a size_t is the check to make before a malloc(n * size) when n comes from outside. To learn to write these checks yourself, without the header, practice on checked_size for multiplication, or here for addition:
add_overflows
Tell whether a + b would fall outside the range of an int, without ever computing a + b. The method is the one ckd_add spares you: compare a with INT_MAX - b or INT_MIN - b depending on the sign of b, exact right down to the bounds.
<stdbit.h>: count and manipulate bits
<stdbit.h> collects operations people used to write by hand or borrow from GCC's own builtins: counting the 1 bits, the leading or trailing zeros, the significant width, the next power of 2. Each function exists for every unsigned type, plus a type-generic version.
#include <stdbit.h>#include <stdio.h>
int main(void){ unsigned x = 0b1011'0000;
printf("%u bits set to 1\n", stdc_count_ones(x)); printf("%u trailing zeros\n", stdc_trailing_zeros(x)); printf("%u significant bits\n", stdc_bit_width(x)); printf("%u, next power of 2\n", stdc_bit_ceil(x)); printf("64 is a power of 2: %d\n", stdc_has_single_bit(64u)); return 0;}$ gcc -Wall -Wextra stdbit.c -o stdbit && ./stdbit3 bits set to 14 trailing zeros8 significant bits256, next power of 264 is a power of 2: 1This header comes from the C library: glibc provides it since version 2.39.
static_assert without a message, and the {} initializer
static_assert becomes a keyword (no more <assert.h>) and its message becomes optional. Empty braces {} initialize everything to zero, including a variable-length array, which {0} couldn't do.
#include <stdio.h>
struct point { int x, y; };
static_assert(sizeof(int) >= 4);
int main(void){ int n = 3; int t[n] = {}; struct point p = {};
printf("%d %d %d / %d %d\n", t[0], t[1], t[2], p.x, p.y); return 0;}$ gcc -Wall -Wextra init.c -o init && ./init0 0 0 / 0 0$ gcc -c assert-bad.cassert-bad.c:1:1: error: static assertion failed 1 | static_assert(sizeof(long) == 4); | ^~~~~~~~~~~~~assert-bad.c contains only the line shown: on this 64-bit machine a long is 8 bytes, so compilation stops.
strdup and strndup join the standard
strdup(3) has existed in POSIX for decades, but not in the C standard: in strict mode, <string.h> didn't declare it. C23 adds it, together with strndup.
$ gcc -std=c17 -Wall -Wextra strdup.c -o strdupstrdup.c: In function 'main':strdup.c:7:18: error: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration] 7 | char *copy = strdup("Grademe"); | ^~~~~~ | strcmpstrdup.c:7:18: error: initialization of 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]$ gcc -std=c23 -Wall -Wextra strdup.c -o strdup && ./strdupGrademeThe same file, a strdup followed by a free, fails in strict C17 and compiles in C23. In gnu17 mode, the old default, glibc already declared it, which is why everyone has always used it without thinking twice.
unreachable(): promise that a path can't happen
unreachable(), from <stddef.h>, tells the compiler that a line will never be reached, which lets it optimize accordingly. If the line is reached anyway, that's undefined behavior. During development, -fsanitize=undefined turns the promise into a check:
#include <stddef.h>#include <stdio.h>
enum color { RED, GREEN };
const char *name(enum color c){ switch (c) { case RED: return "red"; case GREEN: return "green"; } unreachable();}
int main(void){ printf("%s\n", name(GREEN)); printf("%s\n", name(2)); return 0;}$ gcc -Wall -Wextra -g -fsanitize=undefined unreachable.c -o unreachable && ./unreachablegreenunreachable.c:12:5: runtime error: execution reached an unreachable program pointname(2) passes a value that matches no color, and execution reaches line 12. Without the sanitizer, at -O2, nobody can tell you what the program will do. Keep unreachable() for cases your code truly guarantees; otherwise you're writing the bug yourself, and undefined behavior will make it invisible.
What depends on your C library
The compiler doesn't do everything. %b, <stdbit.h>, strdup in strict mode and memset_explicit (a memset the optimizer isn't allowed to remove, for wiping a password) all come from the C library. With glibc 2.41, the version in Debian 13, memset_explicit doesn't exist yet:
$ gcc -Wall -Wextra memset.c -o memsetmemset.c: In function 'main':memset.c:7:5: error: implicit declaration of function 'memset_explicit' [-Wimplicit-function-declaration] 7 | memset_explicit(password, 0, sizeof password); | ^~~~~~~~~~~~~~~$ ldd --version | head -n 1ldd (Debian GLIBC 2.41-12+deb13u4) 2.41It arrived in glibc 2.43. On your machine, ldd --version shows the installed version.
Which version supports each feature
| Feature | Header | Supported since |
|---|---|---|
nullptr, nullptr_t | <stddef.h> for nullptr_t | GCC 13 |
bool, true, false as keywords | none | GCC 13 |
constexpr for objects | none | GCC 13 |
typeof, typeof_unqual | none | GCC 13 (typeof earlier, as an extension) |
auto for variables | none | GCC 13 |
[[deprecated]] | none | GCC 10 |
[[nodiscard]] | none | GCC 11 |
[[maybe_unused]] | none | GCC 10, GCC 13 on labels |
0b literals | none | GCC 4.3, as an extension before C23 |
' separator | none | GCC 12 |
%b in printf | <stdio.h> | glibc 2.35 |
#embed | none | GCC 15 |
ckd_add, ckd_sub, ckd_mul | <stdckdint.h> | GCC 14 |
| bit functions | <stdbit.h> | glibc 2.39 |
static_assert without a message | none | GCC 9 |
{} initializer | none | GCC 13 |
strdup, strndup | <string.h> | glibc, declared in strict C23 |
unreachable() | <stddef.h> | GCC 13 |
memset_explicit | <string.h> | glibc 2.43 |
With GCC 13 or 14, the features the compiler handles need -std=c2x (GCC 14 also accepts -std=c23). With GCC 15, they're on by default.
Frequently asked questions
Do I need -std=c23 to use C23?
Not with GCC 15, which compiles as gnu23 by default. With GCC 14, use -std=c23; with GCC 13, -std=c2x. -std=c23 only turns off the GNU extensions that conflict with the standard; to be warned about all the others, add -Wpedantic.
Should I replace NULL with nullptr everywhere?
In new code compiled as C23, nullptr is safer: its type is the same everywhere, while the type of NULL depends on the implementation. In a project that still has to compile as C17, keep NULL.
What's the difference between const and constexpr?
const forbids modifying a variable, but its value may only be known at run time, and it isn't a constant in the compiler's sense. constexpr requires a value that can be computed at compile time, checks that it fits the type, and lets you use it as the size of a global array or in a case label.
Does auto in C work like in C++?
Only for declaring an initialized variable. C23 allows it neither for a function's return type nor for its parameters. And auto keeps its old meaning too, as a storage class, the one nobody ever wrote.
Does my old code still compile as C23?
Almost always, but not always: typedef int bool; and f() declarations without void no longer compile, and K&R-style function definitions are no longer valid C23. The errors and their fixes are detailed in GCC 15 defaults to C23.
Sources
