Strings and Text Processing

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C has no string type — only char arrays terminated by '\0', as covered in Arrays and Strings. This page is about the library that works on them, the safe idioms, and what happens once the text stops being ASCII.

<string.h> in Practice

Length and Copying

#include <stdio.h>
#include <string.h>

int main(void)
{
    const char *source = "hello";
    char destination[16];

    size_t length = strlen(source);          // 5 -- O(n), does not count the NUL

    // strcpy: no bounds check whatsoever. Only safe when you have PROVEN the fit.
    if (length + 1 <= sizeof destination) {
        strcpy(destination, source);
    }

    // strncpy is NOT a safe strcpy: it pads with NULs if shorter, and does NOT
    // terminate if the source is n bytes or longer.
    char fixed[4];
    strncpy(fixed, "abcdef", sizeof fixed);  // fixed is 'a','b','c','d' -- no NUL!
    fixed[sizeof fixed - 1] = '\0';          // you must terminate it yourself

    // snprintf is the actually-safe copy, and it always terminates:
    char safe[4];
    int needed = snprintf(safe, sizeof safe, "%s", "abcdef");
    printf("%s | %s | %s (needed %d, truncated=%d)\n",
           destination, fixed, safe, needed, (size_t)needed >= sizeof safe);
    return 0;
}

Concatenation

#include <stdio.h>
#include <string.h>

int main(void)
{
    char path[32] = "/var";

    // strcat appends and requires the caller to guarantee the room.
    if (strlen(path) + strlen("/log") + 1 <= sizeof path) {
        strcat(path, "/log");
    }

    // The idiom that replaces strcpy + strcat entirely, with one length check:
    char built[32];
    int written = snprintf(built, sizeof built, "%s/%s", "/var/log", "app.log");
    if (written < 0 || (size_t)written >= sizeof built) {
        fputs("path too long\n", stderr);
        return 1;
    }

    printf("%s | %s\n", path, built);
    return 0;
}

Comparison and Searching

#include <stdio.h>
#include <string.h>

int main(void)
{
    const char *text = "the quick brown fox";

    // strcmp returns <0, 0 or >0 -- it is NOT a boolean.
    printf("%d %d %d\n",
           strcmp("abc", "abc") == 0,        // 1: equal
           strcmp("abc", "abd") < 0,         // 1: "abc" sorts first
           strncmp("abcdef", "abcxyz", 3) == 0);   // 1: first 3 bytes match

    const char *found = strchr(text, 'q');           // first 'q', or nullptr
    const char *last = strrchr(text, 'o');           // last 'o'
    const char *word = strstr(text, "brown");        // substring

    printf("%s | %s | %s\n",
           found != nullptr ? found : "(none)",
           last != nullptr ? last : "(none)",
           word != nullptr ? word : "(none)");

    // Spans: how many leading bytes are (or are not) in a set.
    printf("%zu %zu\n", strspn("128abc", "0123456789"), strcspn("abc=def", "="));
    return 0;
}

Tokenizing

strtok modifies its input and keeps hidden static state, which makes it unusable in a library, in a nested loop, or in threaded code:

#include <stdio.h>
#include <string.h>

int main(void)
{
    // strtok WRITES to its input, so a literal would be undefined -- use an array.
    char input[] = "alpha,beta,,gamma";

    for (char *token = strtok(input, ","); token != nullptr; token = strtok(nullptr, ",")) {
        printf("[%s] ", token);          // note: consecutive delimiters are merged
    }
    putchar('\n');
    return 0;
}

The reentrant alternative — no hidden state, and empty fields preserved — is a hand-rolled split on strcspn:

#include <stdio.h>
#include <string.h>

int main(void)
{
    const char *input = "alpha,beta,,gamma";
    const char *cursor = input;

    while (*cursor != '\0') {
        size_t field_length = strcspn(cursor, ",");

        printf("[%.*s] ", (int)field_length, cursor);    // print without copying

        cursor += field_length;
        if (*cursor == ',') {
            ++cursor;                                    // step over the delimiter
        }
    }
    putchar('\n');
    return 0;
}

strtok_s (Annex K) and strtok_r (POSIX) are the reentrant library versions where available.

Memory Functions

#include <stdio.h>
#include <string.h>

int main(void)
{
    unsigned char buffer[16];

    memset(buffer, 0, sizeof buffer);           // fill with a byte value

    const unsigned char source[4] = { 1, 2, 3, 4 };
    memcpy(buffer, source, sizeof source);      // regions must NOT overlap

    // Shift within one buffer -- overlapping, so memmove is required:
    memmove(buffer + 1, buffer, 4);

    printf("%d %d %d %d %d\n", buffer[0], buffer[1], buffer[2], buffer[3], buffer[4]);

    // memcmp compares BYTES, so it is right for buffers and wrong for structs
    // (padding bytes are indeterminate).
    printf("equal: %d\n", memcmp(source, source, sizeof source) == 0);

    const unsigned char *hit = memchr(buffer, 3, sizeof buffer);
    printf("found 3 at offset %td\n", hit != nullptr ? hit - buffer : -1);
    return 0;
}

memcpy with overlapping regions is undefined — and because its parameters are restrict, the optimizer genuinely relies on that. When in doubt, memmove.

C23 adds memset_explicit, a memset the compiler is forbidden to optimize away — the fix for wiping a key buffer that the optimizer previously deleted as a dead store:

#include <string.h>

static void handle_secret(void)
{
    unsigned char key[32];
    /* ... use the key ... */

    memset_explicit(key, 0, sizeof key);    // C23: guaranteed not to be elided
}

strdup and strndup

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // C23 standardizes both (POSIX had them for decades). The caller owns the result.
    char *copy = strdup("hello");
    char *prefix = strndup("hello world", 5);

    if (copy == nullptr || prefix == nullptr) {
        free(copy);
        free(prefix);
        return EXIT_FAILURE;
    }

    printf("%s | %s\n", copy, prefix);
    free(copy);
    free(prefix);
    return 0;
}

<ctype.h> — Character Classification

#include <ctype.h>
#include <stdio.h>

int main(void)
{
    const char *text = "Hello, C23!";

    for (const char *p = text; *p != '\0'; ++p) {
        // The argument must be representable as unsigned char, or be EOF:
        // pass a plain char directly and a negative value is UNDEFINED BEHAVIOR.
        int c = (unsigned char)*p;

        if (isalpha(c)) {
            putchar(toupper(c));
        } else if (isdigit(c)) {
            putchar('#');
        } else if (isspace(c)) {
            putchar('_');
        } else if (ispunct(c)) {
            putchar('.');
        }
    }
    putchar('\n');

    printf("%d %d %d %d\n", isalnum('a'), isxdigit('f'), isupper('A'), iscntrl('\n'));
    return 0;
}

The (unsigned char) cast is not optional. char is signed on x86 Linux, so isalpha(*p) on a byte like 0xE9 passes -23 and indexes outside the classification table.

These functions are also locale-dependent and single-byte only — they cannot classify a UTF-8 sequence.

Numeric Conversion

#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    // strtol: the correct integer parser. atoi has no error reporting at all
    // (and is undefined on overflow) -- never use it.
    errno = 0;
    char *end = nullptr;
    long value = strtol("  -42rest", &end, 10);      // leading space is skipped

    printf("value=%ld, stopped at \"%s\", errno=%d\n", value, end, errno);

    // Base 0 auto-detects the prefix: 0x hex, 0b binary (C23), 0 octal, else decimal.
    printf("%ld %ld %ld\n",
           strtol("0x1F", nullptr, 0), strtol("0755", nullptr, 0), strtol("99", nullptr, 0));

    // Overflow is reported through errno, with the value clamped.
    errno = 0;
    long clamped = strtol("99999999999999999999", &end, 10);
    printf("clamped=%ld erange=%d\n", clamped, errno == ERANGE);

    // The wider and floating-point variants:
    unsigned long ul = strtoul("4000000000", nullptr, 10);
    long long ll = strtoll("-9000000000", nullptr, 10);
    intmax_t im = strtoimax("123", nullptr, 10);
    double d = strtod("3.14e2", nullptr);
    float f = strtof("1.5", nullptr);

    printf("%lu %lld %" PRIdMAX " %g %g\n", ul, ll, im, d, (double)f);
    return 0;
}

The complete strtol error protocol — all four checks are needed:

  1. Set errno = 0 before the call.

  2. If end == input, no digits were found.

  3. If *end != '\0' (after skipping any whitespace you allow), there was trailing garbage.

  4. If errno == ERANGE, the value overflowed and was clamped to LONG_MIN/LONG_MAX.

See the parse_int function in Input, Output and Files for that written out.

Locales

#include <locale.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // A program starts in the "C" locale, NOT the user's -- this is deliberate,
    // and you must opt in.
    const char *previous = setlocale(LC_ALL, "");     // "" means the environment's locale
    printf("locale is now: %s\n", setlocale(LC_ALL, nullptr));

    struct lconv *conv = localeconv();
    printf("decimal point: \"%s\", thousands sep: \"%s\", currency: \"%s\"\n",
           conv->decimal_point, conv->thousands_sep, conv->currency_symbol);

    // strcoll compares according to the locale's collation order;
    // strcmp compares byte values. For user-visible sorting, use strcoll.
    printf("strcmp=%d strcoll=%d\n",
           strcmp("apple", "Apple") < 0, strcoll("apple", "Apple") < 0);

    (void)previous;
    return 0;
}

The categories are LC_ALL, LC_COLLATE, LC_CTYPE, LC_MONETARY, LC_NUMERIC and LC_TIME. The trap worth knowing: setlocale(LC_ALL, "") changes LC_NUMERIC, so strtod/printf("%f") start using the locale’s decimal separator — a comma in much of Europe, which silently breaks data files. Set only the categories you actually need, and keep LC_NUMERIC as "C" for machine-readable I/O.

UTF-8 and Extended Character Sets

C’s model is deliberately minimal: bytes in, bytes out, with conversion functions in the middle. A UTF-8 string is just a char array, and most <string.h> functions work on it unchanged — because no UTF-8 continuation byte is ever '\0' or matches an ASCII byte.

#include <stdio.h>
#include <string.h>
#include <uchar.h>
#include <wchar.h>

int main(void)
{
    // u8"" literals are guaranteed UTF-8. C23 gives them the type char8_t[]
    // (char8_t being unsigned char); reading them through const char * is what
    // portable code does today -- see the note below.
    const char *utf8 = u8"café";           // 5 bytes: c a f 0xC3 0xA9

    printf("%s\n", utf8);
    printf("strlen (BYTES) = %zu\n", strlen(utf8));          // 5, not 4

    // Counting CHARACTERS means counting non-continuation bytes:
    size_t characters = 0;
    for (const char *p = utf8; *p != '\0'; ++p) {
        if (((unsigned char)*p & 0xC0u) != 0x80u) {          // not 10xxxxxx
            ++characters;
        }
    }
    printf("characters      = %zu\n", characters);           // 4

    // The other literal prefixes:
    const char16_t *utf16 = u"café";       // UTF-16 code units
    const char32_t *utf32 = U"café";       // UTF-32 code points
    const wchar_t *wide = L"café";         // implementation-defined width

    printf("%u %u %u\n", (unsigned)utf16[3], (unsigned)utf32[3], (unsigned)wide[3]);
    return 0;
}

C23 specifies that a u8"" literal has type char8_t[N], with char8_t a typedef for unsigned char in <uchar.h>. Compilers are still catching up — Clang 18, used to verify these examples, still types u8"" literals as char[N] in C mode, so assigning one to a const char8_t * warns under -Wpointer-sign. Reading UTF-8 through const char * (as above) works on every compiler and every edition; add an explicit (const char8_t *) cast only where you specifically want the unsigned char type.

The rule that follows: strlen counts bytes, not characters, and a UTF-8 string must never be split, indexed or truncated at an arbitrary byte offset. Slice only at a byte that is not a continuation byte.

Restartable Conversions — <uchar.h> and <wchar.h>

Multibyte-to-wide conversion is stateful (a partial sequence may span two buffers), so the r functions take an mbstate_t:

#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <uchar.h>

int main(void)
{
    setlocale(LC_CTYPE, "");                // conversion depends on LC_CTYPE

    const char *input = "café";
    size_t remaining = strlen(input) + 1;   // include the terminator: it ends the loop
    const char *cursor = input;

    mbstate_t state;
    memset(&state, 0, sizeof state);        // a fresh conversion state

    char32_t code_point;
    size_t consumed;

    while ((consumed = mbrtoc32(&code_point, cursor, remaining, &state)) != 0) {
        if (consumed == (size_t)-1 || consumed == (size_t)-2) {
            fputs("invalid or incomplete multibyte sequence\n", stderr);
            return 1;
        }
        if (consumed == (size_t)-3) {
            continue;                       // another code unit from the same character
        }

        printf("U+%04X ", (unsigned)code_point);
        cursor += consumed;
        remaining -= consumed;
    }
    putchar('\n');

    // The reverse direction, plus the wide-character equivalents in <wchar.h>:
    char out[MB_CUR_MAX];
    memset(&state, 0, sizeof state);
    size_t produced = c32rtomb(out, U'é', &state);
    printf("encoded %zu bytes\n", produced);
    return 0;
}

The return-value protocol is the fiddly part: 0 means a null character was converted, (size_t)-1 an encoding error, (size_t)-2 an incomplete sequence at the end of the buffer, and (size_t)-3 that another code unit was produced from a character already consumed.

<wchar.h> mirrors <string.h> for wchar_t (wcslen, wcscpy, wprintf), but wchar_t is 32-bit on Linux and 16-bit on Windows — so it is not a portable Unicode type. For serious text work, treat data as UTF-8 bytes end to end and use char32_t only where you must inspect individual code points; anything beyond that (normalization, case folding, grapheme clusters, collation) needs ICU or utf8proc.

See Also