C23 at a glance

C23, published by ISO in 2024 as ISO/IEC 9899:2024, is the biggest change to C since C99. A compiler in C23 mode sets __STDC_VERSION__ to 202311L. The table lists the changes most programmers notice, with the first GCC and Clang versions that support each one according to the compilers' own status pages:

Change Example GCC Clang
null pointer constant int *p = nullptr; 13 17
keywords instead of macros bool, true, false, static_assert, alignas, thread_local 13 17
compile-time constants constexpr int METERS = 400'000; 13 19
type of an expression typeof(x), typeof_unqual(x) 13 16
type inference auto price = 2700; 13 18
standard attributes [[nodiscard]], [[deprecated]], [[fallthrough]] 10–13 yes
checked integer arithmetic ckd_mul(&r, a, b) 14 yes
embedding a file #embed "device-ca.pem" 15 19
digit separators 3'504'000'000 12 13
bit-precise integers _BitInt(12) 14 15
enums with a fixed type enum status : unsigned char { … } 13 20

C23 also removes and tightens things. According to the change list in the standard's Annex M:

  • Signed integers must be two's complement; the old ones' complement and sign-magnitude representations are gone. Signed overflow is still undefined behavior (see Undefined Behavior and Safer C).
  • K&R-style function definitions with an identifier list are removed, and an empty parameter list now means "no parameters": int f() is the same as int f(void) (see Functions and Header Files).
  • POSIX functions such as strdup, strndup, memccpy, gmtime_r and localtime_r join the standard library, along with memset_explicit (a memset the compiler may not optimize away, for wiping secrets) and the new header <stdbit.h> for bit and byte utilities.
  • Labels may appear before declarations, parameters may be unnamed in a definition, and = {} initializes any object to zero.

Compiler support today

As of October 2026 the newest releases are GCC 16.2 (August 2026) and LLVM/Clang 23.1.3 (October 2026). What matters more in practice is which version a server or toolchain actually has: Debian 12, for example, ships GCC 12, which knows the option -std=c2x but implements little of C23.

Compiler Default dialect How to ask for C23
GCC 15 and later gnu23 (C23 with GNU extensions) -std=c23 (default already close)
GCC 14 gnu17 -std=c23 (first version with this spelling)
GCC 9 to 13 gnu17 -std=c2x, partial
Clang 18 and later gnu17 -std=c23

A program can check which standard it is compiled for:

#include <stdio.h>

int main(void)
{
#if __STDC_VERSION__ >= 202311L
    printf("C23 or later (%ld)\n", __STDC_VERSION__);
#else
    printf("before C23 (%ld)\n", __STDC_VERSION__);
#endif
    return 0;
}
C23 or later (202311)

The default change in GCC 15 matters even if you never write C23. Old code that defines its own bool, true or false, or that calls int f() with arguments, stops compiling when a build machine is upgraded. And some C23 code compiles on an older compiler with a different meaning. Before C23, auto was an obsolete storage class and a missing type meant int, so GCC 12 accepts this with only a warning and computes with an integer price of 0:

$ cat price.c
#include <stdio.h>

int main(void)
{
    auto price = 0.27;
    printf("%.2f EUR\n", price * 350.0);
    return 0;
}
$ gcc -std=c2x -o price price.c && ./price
price.c: In function ‘main’:
price.c:5:10: warning: type defaults to ‘int’ in declaration of ‘price’ [-Wimplicit-int]
    5 |     auto price = 0.27;
      |          ^~~~~
0.00 EUR
$ clang -std=c23 -o price price.c && ./price
94.50 EUR

Pin the standard explicitly in every build (-std=c17 or -std=c23), set a minimum compiler version, and treat warnings as errors: then a too-old compiler fails loudly instead of quietly. How the flags work is explained in Compiling and Running C.

nullptr and the new keywords

nullptr is the C23 keyword for the null pointer constant. Unlike the macro NULL, which a C library may define as 0 or as ((void *)0), it always has its own type, nullptr_t, and converts only to pointer types and bool. That removes the ambiguity when a null pointer is passed where the type is not known, such as to a variadic function or a _Generic selection. The idea of a null pointer is explained in Pointers.

#include <stddef.h>
#include <stdio.h>

#define TYPE_NAME(x) _Generic((x), \
    nullptr_t: "nullptr_t",        \
    void *: "void *",              \
    int: "int",                    \
    default: "something else")

int main(void)
{
    int *latest = nullptr;          /* no reading received yet */
    bool online = false;            /* bool, true and false are keywords now */

    printf("NULL has type %s\n", TYPE_NAME(NULL));
    printf("nullptr has type %s\n", TYPE_NAME(nullptr));
    printf("latest is %s, online is %d\n",
           latest == nullptr ? "empty" : "set", online);
    return 0;
}
NULL has type void *
nullptr has type nullptr_t
latest is empty, online is 0

With glibc, NULL happens to be ((void *)0); with another C library it can be plain 0, an int. The program also uses bool and false without including <stdbool.h>: in C23 they are keywords, as are static_assert, alignas, alignof and thread_local, which used to be spelled _Static_assert, _Alignas and so on. static_assert may now also leave out its message.

Constants and type inference

constexpr

constexpr declares an object whose value must be known at compile time and can be used wherever a constant expression is required: array sizes, case labels, static_assert. It is a typed alternative to an object-like macro. C's version is deliberately smaller than C++'s: it applies to objects only, not to functions.

#include <stdio.h>

constexpr int METERS = 400'000;
constexpr int HOURS_PER_YEAR = 8'760;
constexpr long long METER_HOURS = (long long)METERS * HOURS_PER_YEAR;
constexpr unsigned STATUS_MASK = 0b1010'0001;

static_assert(METER_HOURS == 3'504'000'000);

int main(void)
{
    int per_district[METERS / 100'000] = {};   /* a constant size, zeroed */
    printf("meter-hours per year: %lld\n", METER_HOURS);
    printf("%zu districts, first total %d, mask %u\n",
           sizeof per_district / sizeof per_district[0], per_district[0],
           STATUS_MASK);
    return 0;
}
meter-hours per year: 3504000000
4 districts, first total 0, mask 161

A constexpr value must fit its type exactly. Where a macro or a plain const only gets a warning and goes on with a wrong value, a constexpr is refused:

#include <stdio.h>

constexpr int METERS = 400'000;
constexpr int METER_HOURS = METERS * 8'760;

int main(void)
{
    printf("%d\n", METER_HOURS);
    return 0;
}
example.c:4:15: error: constexpr variable 'METER_HOURS' must be initialized by a constant expression

Clang's next line, a note, gives the reason: "value 3504000000 is outside the range of representable values of type 'int'".

Digit separators and binary literals

A digit separator is a single quote between the digits of a numeric literal, such as 400'000 or 0b1010'0001, that makes long numbers readable and is ignored by the compiler. C23 uses the quote rather than the underscore of Python and Java to match C++14, where an underscore would clash with user-defined literal suffixes. C23 also standardizes binary literals with 0b, which GCC and Clang accepted as an extension for years; the mask above is 161.

typeof and auto

The typeof operator gives the type of an expression (or a type name) at compile time, so a declaration can say "a variable of the same type as this one"; typeof_unqual does the same but drops qualifiers such as const. GCC and Clang offered typeof as an extension long before C23, and macros in the Linux kernel use it widely. Its classic use is a type-generic macro that evaluates each argument once (see The Preprocessor and Build Tools).

#include <stdio.h>

/* Swap two variables of any (same) type, evaluating each argument once. */
#define SWAP(a, b)               \
    do {                         \
        typeof(a) *pa_ = &(a);   \
        typeof(b) *pb_ = &(b);   \
        typeof(a) tmp_ = *pa_;   \
        *pa_ = *pb_;             \
        *pb_ = tmp_;             \
    } while (0)

int main(void)
{
    int low = 860, high = 95;
    SWAP(low, high);
    printf("%d %d\n", low, high);

    double day = 0.2900, night = 0.2500;
    SWAP(day, night);
    printf("%.4f %.4f\n", day, night);

    const int limit = 2300;
    typeof_unqual(limit) counter = 0;   /* int, without const */
    counter += limit;
    auto price = 2700;                  /* inferred: int */
    auto kwh = 350L;                    /* inferred: long */
    printf("%d %ld\n", counter, price * kwh);
    return 0;
}
95 860
0.2500 0.2900
2300 945000

auto in C23 infers a variable's type from its initializer, like type inference in other languages, but only for single objects: no auto return types or parameters. Use it sparingly; with C's implicit conversions an explicit type often says more. 2700 is an int and 350L a long, and the inferred types follow the literals exactly.

Attributes

An attribute specifier is the C23 syntax [[name]] (or [[name(arguments)]]) that attaches extra information to a declaration or statement, such as "the result must be used" or "this function is deprecated", for the compiler to check or use. Before C23 each compiler had its own syntax (__attribute__((warn_unused_result)) in GCC and Clang); vendor attributes now have a namespaced form such as [[gnu::packed]]. The standard ones:

Attribute Meaning
[[nodiscard]] warn when the caller ignores the return value
[[deprecated("reason")]] warn when the declaration is used
[[fallthrough]] a switch case falls through on purpose
[[maybe_unused]] no warning if this parameter or variable is unused
[[noreturn]] the function never returns (like exit)
[[unsequenced]], [[reproducible]] promises about a function's side effects, for optimization; Clang 21 still ignored them

[[nodiscard]] turns C's habit of silently ignored return values (see Error Handling) into a compiler error, with warnings treated as errors:

#include <stdio.h>

[[nodiscard]] static int send_reading(int watts)
{
    return watts >= 0 ? 0 : -1;   /* 0 = sent, -1 = rejected */
}

int main(void)
{
    send_reading(1234);
    return 0;
}
example.c:10:5: error: ignoring return value of function declared with 'nodiscard' attribute [-Werror,-Wunused-result]

[[deprecated]] lets a library tell its users which function to stop using, with the reason in the message:

#include <stdio.h>

[[deprecated("use read_power_w, which returns whole watts")]]
static double read_power_kw(void)
{
    return 1.234;
}

int main(void)
{
    printf("%.3f kW\n", read_power_kw());
    return 0;
}
example.c:11:25: error: 'read_power_kw' is deprecated: use read_power_w, which returns whole watts [-Werror,-Wdeprecated-declarations]

[[fallthrough]] and [[maybe_unused]] do the opposite: they tell the compiler that something which looks like a mistake is intended, so the warning (-Wimplicit-fallthrough, part of GCC's -Wextra, and -Wunused-parameter) disappears without switching it off for the whole program (see Control Flow and Loops):

#include <stdio.h>

static int read_power_w(void) { return 1234; }

[[nodiscard]] static int send_reading(int watts)
{
    return watts >= 0 ? 0 : -1;
}

/* Mode 2 does everything mode 1 does, plus a check first. */
static void handle(int mode, [[maybe_unused]] int home)
{
    switch (mode) {
    case 2:
        printf("range check\n");
        [[fallthrough]];            /* intended: continue with mode 1 */
    case 1:
        printf("store reading\n");
        break;
    default:
        printf("unknown mode\n");
        break;
    }
}

int main(void)
{
    if (send_reading(read_power_w()) != 0) {
        printf("reading rejected\n");
    }
    handle(2, 17);
    return 0;
}
range check
store reading

Checked integer arithmetic

Checked integer arithmetic performs an addition, subtraction or multiplication and reports whether the exact result fits the destination type, instead of overflowing silently or causing undefined behavior. C23 provides it in <stdckdint.h> as ckd_add, ckd_sub and ckd_mul: each stores the result through its first argument and returns true if it did not fit. The operands may have any integer types, because the check is done on the mathematically exact value. Here is the annual meter-hours calculation for Kestrel's 400,000 meters, which does not fit in an int (see Variables, Types and Integers):

#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    int meters = 400'000;
    int hours = 8'760;

    int as_int;
    if (ckd_mul(&as_int, meters, hours)) {
        printf("int: overflow, result unusable\n");
    }

    int64_t as_int64;
    if (!ckd_mul(&as_int64, meters, hours)) {
        printf("int64_t: %lld meter-hours\n", (long long)as_int64);
    }

    unsigned char level;
    bool overflow = ckd_add(&level, 250, 10);
    printf("250 + 10 in unsigned char: overflow=%d, wrapped value %d\n",
           overflow, level);
    return 0;
}
int: overflow, result unusable
int64_t: 3504000000 meter-hours
250 + 10 in unsigned char: overflow=1, wrapped value 4

When the operation overflows, the stored value is the wrapped result (260 − 256 = 4), but the return value tells you not to use it. This is the kind of check that code reading sizes or counts from untrusted input needs before it calls malloc. On compilers without C23 support, GCC and Clang have offered the same check for years as built-in functions: __builtin_mul_overflow(a, b, &result), available in GCC 12 as well.

Embedding files with #embed

#embed is a C23 preprocessor directive that inserts the bytes of a file as a comma-separated list of integers, typically to initialize an array, so that data such as a certificate, an image or a lookup table becomes part of the program at build time. Before C23 that took an external tool such as xxd -i to turn the file into C source, or linker tricks. Firmware is a typical user: a device that must trust a certificate authority can carry the CA certificate inside its image. The certificate file below is a placeholder for the example:

$ cat embed.c
#include <stdio.h>

static const unsigned char ca_cert[] = {
#embed "device-ca.pem"
};

int main(void)
{
    printf("embedded %zu bytes, starting with: %.27s\n",
           sizeof ca_cert, (const char *)ca_cert);
    return 0;
}
$ wc -c device-ca.pem
72 device-ca.pem
$ clang -std=c23 -Wall -Wextra -o embed embed.c && ./embed
embedded 72 bytes, starting with: -----BEGIN CERTIFICATE-----

The array gets exactly the file's bytes, with no terminating zero added, which is why the program prints with a precision (%.27s) instead of treating the array as a string. Parameters such as limit(64) take only part of a file, and __has_embed checks whether a file can be embedded. GCC supports #embed from version 15 and Clang from 19.

What comes next: C2y

C2y is the working name of the revision of the C standard after C23, under development by the ISO committee WG14; the "y" stands for a year that is not fixed yet. Compilers accept -std=c2y for the features they already implement (Clang 21 then reports __STDC_VERSION__ as 202400L, a placeholder). As of October 2026 the latest working draft is N3886 (May 2026). Features that GCC and Clang already list for C2y on their status pages include:

Feature Example First support
_Countof: number of elements of an array _Countof watts GCC 16, Clang 21
declarations in if conditions if (int n = read(); n > 0) GCC 15
named loops break outer; GCC 15, Clang 22
octal prefix 0o 0o755 instead of 0755 GCC 15, Clang 21
case ranges case 1 ... 9: already a GNU extension in GCC and Clang

_Countof finally replaces the sizeof a / sizeof a[0] idiom, and fails to compile on a pointer instead of giving a wrong answer (see Arrays and Pointer Arithmetic):

$ cat count.c
#include <stdio.h>

int main(void)
{
    int watts[] = {120, 95, 130, 110};
    printf("%zu readings\n", _Countof watts);
    return 0;
}
$ clang -std=c2y -Wall -Wextra -o count count.c && ./count
4 readings

Named loops are the C version of labeled statements in JavaScript and Java: break outer; leaves an enclosing loop directly, a common reason for goto today.

Where C is used today

What Is C? lists the places where C runs: operating system kernels, firmware, language runtimes, databases and core libraries. Three of them are worth a closer look, because they decide how C changes.

Kernels

The Linux kernel is written in C and built as GNU C11 (-std=gnu11), so it uses GNU extensions such as typeof and statement expressions and is built freestanding, without the C standard library (see hosted and freestanding). The kernel can also contain code in Rust, so far mainly for drivers; according to LWN's report on the December 2025 Maintainers Summit, Rust support is no longer considered an experiment. The C core is not being rewritten: new Rust code sits next to millions of lines of C and calls it through its C interfaces.

Firmware

Microcontroller firmware is still mostly C (see Firmware and Embedded Programming). The toolchains move fast too: ESP-IDF v6.1 (August 2026), the framework for the ESP32 family, ships a GCC 15.2 toolchain, so whether firmware is compiled as C17 or C23 now depends on the -std setting in its build rather than on the compiler's age. For the Kestrel Home pilot this is an outside manufacturer's concern: the ESP32-C3 dongles run the manufacturer's C firmware, and Kestrel decides when a version such as 1.4.0 rolls out (CHG-5012). Knowing what modern C offers helps when reading the manufacturer's release notes or asking how their parser is tested (see fuzzing).

C as the common interface

Almost every language can call a function compiled from C, because each platform defines a C ABI (application binary interface): how arguments are passed, how structs are laid out, how names appear to the linker. That is why a library written in C, C++ or Rust usually offers a C interface, and why Python, Java, C# and Rust programs end up calling C. Python's standard ctypes module calls the C library directly; it only has to be told each function's signature, because a compiled C function does not carry its types:

import ctypes
import ctypes.util

# Load the C library that this Python process already uses.
libc = ctypes.CDLL(ctypes.util.find_library("c"))

# Describe the C function's signature: size_t strlen(const char *s);
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t

serial = "KHP1-000231".encode("ascii")
print("library:", ctypes.util.find_library("c"))
print("strlen from C:", libc.strlen(serial))

# int abs(int) works the same way.
libc.abs.argtypes = [ctypes.c_int]
libc.abs.restype = ctypes.c_int
print("abs(-1234) from C:", libc.abs(-1234))
library: libc.so.6
strlen from C: 11
abs(-1234) from C: 1234

The flip side: a wrong signature, or a Python string passed where C expects a buffer it may write into, crashes the Python process with the same segmentation faults as a C program (see Debugging C). Memory safety ends at the boundary with C.

C in a memory-safe direction

Government guidance from the United States and its partners since 2022 recommends memory-safe languages for new code where practical, and roadmaps for existing C and C++ code (the details and sources are in Undefined Behavior and Safer C). For C that means two parallel movements. New components, such as new drivers or parsers for untrusted input, are increasingly written in Rust or another memory-safe language. And the enormous amount of existing C is kept and made safer: stricter warnings, sanitizers and fuzzing in every pipeline, hardening flags, coding standards, and the C23 features on this page that move checks from the programmer's memory to the compiler, such as [[nodiscard]], constexpr range checks and ckd_mul. Both movements will run for decades, which is why C remains worth understanding, whether you write it, operate it or read its advisories.