Skip to content

C Strings, Bytes, and Text Encodings

C does not have one owning string value type. Most traditional interfaces use a pointer to a null-terminated sequence of char, while robust programs also pass explicit lengths for buffers, slices, files, and network data.

A string literal creates an array

c
const char *name = "Ada";

The literal contains {'A', 'd', 'a', '\0'}. Modifying a string literal is undefined behavior, so point to it through const char *. An initialized array creates writable storage:

c
char name[] = "Ada";
name[0] = 'E';

strlen counts bytes before the first null byte; it does not include the terminator and does not count user-perceived characters.

Buffers need capacity and current length

An API that writes text should know destination capacity:

c
int format_label(char *destination, size_t capacity, int value);

snprintf reports the length that would have been written, allowing truncation detection:

c
#include <stdio.h>

int main(void) {
    char buffer[16];
    int needed = snprintf(buffer, sizeof buffer, "item-%d", 42);
    if (needed < 0 || (size_t)needed >= sizeof buffer) {
        return 1;
    }
    puts(buffer);
    return 0;
}

Functions such as strcpy require the caller to prove sufficient capacity. Bounded functions are not automatically safe: some have surprising padding or termination rules. Learn each contract and keep size calculations beside the allocation or array.

Bytes are not characters

UTF-8 represents one Unicode scalar value with one to four bytes. When the execution encoding is UTF-8, strlen("é") commonly reports two rather than one, and grapheme clusters can contain multiple scalar values. ISO C does not require the execution character set to be UTF-8, so even that byte count is an environment assumption.

C's execution character sets, multibyte functions, wide characters, locale state, and newer UTF-oriented types form a complicated portability area. A practical cross-platform policy is often:

  • define external text as UTF-8;
  • keep byte length explicit;
  • use a proven Unicode library for normalization, segmentation, or case mapping;
  • convert deliberately at Windows UTF-16 API boundaries;
  • never assume one char equals one displayed character.

Parsing should report boundaries and errors

atoi cannot distinguish invalid input from a valid zero and does not expose overflow. strtol provides an end pointer and error signaling:

c
#include <errno.h>
#include <limits.h>
#include <stdlib.h>

static int parse_int(const char *text, int *result) {
    char *end = NULL;
    errno = 0;
    long value = strtol(text, &end, 10);
    if (text == end || *end != '\0' || errno == ERANGE ||
        value < INT_MIN || value > INT_MAX) {
        return 0;
    }
    *result = (int)value;
    return 1;
}

External bytes are untrusted even when a type cast or terminator makes them convenient to access. Validate length before searching for a terminator.

Binary data should use byte-oriented contracts

An arbitrary byte buffer may contain zeros and is not a C string. Represent it with a pointer plus length, often using unsigned char * or uint8_t * where available and appropriate. File and socket reads report the number of bytes actually received; they do not append a terminator unless the program does so within allocated capacity.

Optional prompts

Predict: Does strlen include the terminating null byte?

Answer: No. It counts preceding bytes. Storage for a copied C string generally needs strlen(source) + 1 bytes.

Explain: Why is UTF-8 text not safely indexed by user-visible character using text[i]?

Answer: The index selects one byte. Unicode scalar values may span several bytes, and a displayed grapheme may span several scalar values.

Further reference