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:
-
Set
errno = 0before the call. -
If
end == input, no digits were found. -
If
*end != '\0'(after skipping any whitespace you allow), there was trailing garbage. -
If
errno == ERANGE, the value overflowed and was clamped toLONG_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 |
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
-
Arrays and Strings —
chararrays, literals and the NUL terminator. -
Input, Output and Files —
snprintf,fgetsand robust parsing. -
Dynamic Memory Allocation — building strings on the heap.
-
Error Handling and Program Failure — what to do when text does not parse.
-
C++: Strings and Text —
std::string/std::string_viewandstd::formatreplace most of this bookkeeping.
References
-
WG14 N3220 — the C23 working draft (§7.26 "String handling", §7.4
<ctype.h>, §7.11<locale.h>, §7.30<uchar.h>, §7.31<wchar.h>). -
GNU C Library Reference Manual — String and Array Utilities.