Skip to main content
C · lesson 10 of 25
C beginner Lesson 10 of 25

Strings in C

Learn how C strings work as null-terminated char arrays, how to use string.h functions, and how to handle strings safely.

By SkillByExample · Updated

How C Strings Work

A C string is an array of char that ends with a zero byte, '\0'. That terminator is the only way any function knows where the string stops:

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

int main(void) {
    const char *greeting = "Hello";   /* pointer to a read-only literal */
    char name[] = "Alice";            /* writable copy: 5 letters + '\0' */

    char word[4];
    word[0] = 'H';
    word[1] = 'i';
    word[2] = '!';
    word[3] = '\0';                   /* without this, word is not a string */

    printf("%s, %s. %s\n", greeting, name, word);
    printf("sizeof(name) = %zu, strlen(name) = %zu\n", sizeof name, strlen(name));
    for (size_t i = 0; i <= strlen(name); i++) {
        printf("name[%zu] = %3d '%c'\n", i, name[i], name[i] ? name[i] : '0');
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o basics basics.c
$ ./basics
Hello, Alice. Hi!
sizeof(name) = 6, strlen(name) = 5
name[0] =  65 'A'
name[1] = 108 'l'
name[2] = 105 'i'
name[3] =  99 'c'
name[4] = 101 'e'
name[5] =   0 '0'

sizeof counts the array’s bytes, terminator included (6). strlen counts characters up to the terminator (5). A string of length n always needs n + 1 bytes of storage. The last row shows the terminator itself: a byte with value 0, not the character '0' (which is 48).

Copying and Appending

strcpy and strcat trust that the destination is big enough. The n variants limit how much they write, with a catch each:

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

int main(void) {
    const char *src = "Hello, World!";
    char dst[64];

    printf("strlen: %zu\n", strlen(src));
    strcpy(dst, src);                         /* dst must be big enough */
    printf("strcpy: %s\n", dst);

    char limited[8];
    strncpy(limited, src, sizeof limited - 1);
    limited[sizeof limited - 1] = '\0';       /* strncpy may not terminate */
    printf("strncpy: %s\n", limited);

    char buf[16] = "Hello";
    strncat(buf, ", World!!!", sizeof buf - strlen(buf) - 1);
    printf("strncat: %s (%zu of %zu bytes used)\n", buf, strlen(buf) + 1, sizeof buf);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o copy copy.c
$ ./copy
strlen: 13
strcpy: Hello, World!
strncpy: Hello, 
strncat: Hello, World!!! (16 of 16 bytes used)

strncpy copied exactly 7 bytes, "Hello, " including the trailing space, and did not add a terminator, because the source was longer. The manual limited[7] = '\0' is what makes the result safe to print. strncat’s limit is the space remaining, not the buffer size: 16 − 5 − 1 = 10 characters, which fit exactly and filled all 16 bytes.

Comparing and Searching

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

int main(void) {
    const char *s = "Hello, World!";

    printf("strcmp(\"apple\", \"banana\") = %d\n", strcmp("apple", "banana"));
    printf("strcmp(\"abc\", \"abc\")       = %d\n", strcmp("abc", "abc"));
    printf("strcmp(\"zebra\", \"ant\")     = %d\n", strcmp("zebra", "ant"));
    printf("strncmp first 5 chars       = %d\n", strncmp("hello world", "hello there", 5));

    const char *w = strchr(s, 'W');           /* first match */
    const char *l = strrchr(s, 'l');          /* last match */
    const char *sub = strstr(s, "World");     /* substring */
    printf("'W' at %td, last 'l' at %td, strstr -> \"%s\"\n", w - s, l - s, sub);
    printf("strchr(s, 'z') = %p\n", (void *)strchr(s, 'z'));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o search search.c
$ ./search
strcmp("apple", "banana") = -1
strcmp("abc", "abc")       = 0
strcmp("zebra", "ant")     = 1
strncmp first 5 chars       = 0
'W' at 7, last 'l' at 10, strstr -> "World!"
strchr(s, 'z') = (nil)

This C library returned exactly -1 and 1, but the standard only promises negative, zero or positive. Other libraries return values like -25, so test strcmp(a, b) < 0, never == -1. The search functions return a pointer into the original string, not an index. Subtracting the start pointer turns it into an index, and a miss returns NULL, printed here by glibc as (nil). Comparing strings with == compares addresses, not contents, which is why strcmp exists.

Splitting with strtok

strtok breaks a string into tokens. It does so by writing '\0' bytes into your string:

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

int main(void) {
    char csv[] = "one,two,,three";
    for (char *tok = strtok(csv, ","); tok != NULL; tok = strtok(NULL, ",")) {
        printf("token: '%s'\n", tok);
    }
    printf("csv afterwards: '%s'\n", csv);   /* strtok wrote '\0's into it */
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o tokens tokens.c
$ ./tokens
token: 'one'
token: 'two'
token: 'three'
csv afterwards: 'one'

There are two surprises. The empty field between ,, vanished, because strtok treats a run of delimiters as a single one, which is wrong for real CSV. And csv now looks like just "one", because the first comma was overwritten with a terminator. strtok also keeps hidden state between calls (that’s what the NULL first argument continues), so it can’t tokenize two strings at once. POSIX strtok_r fixes that.

Building Strings Safely with snprintf

snprintf formats into a buffer, never writes more than the size you give it, and always terminates. Its return value tells you if the output didn’t fit:

#include <stdio.h>

void build(size_t size) {
    char buf[64];
    int written = snprintf(buf, size, "%s %s, age %d", "John", "Doe", 30);
    if (written < 0) {
        printf("encoding error\n");
    } else if ((size_t)written >= size) {
        printf("size %2zu: TRUNCATED to \"%s\" (needed %d + 1 bytes)\n", size, buf, written);
    } else {
        printf("size %2zu: \"%s\"\n", size, buf);
    }
}

int main(void) {
    build(64);
    build(10);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o snprintf snprintf.c
$ ./snprintf
size 64: "John Doe, age 30"
size 10: TRUNCATED to "John Doe," (needed 16 + 1 bytes)

With only 10 bytes, snprintf wrote 9 characters plus the terminator and stopped cleanly. It still returned 16, the length the full output would have had. That lets you detect truncation, or allocate exactly written + 1 bytes and call it again. sprintf has no size argument, so the same call on a small buffer would overflow it.

Converting Strings to Numbers

atoi can’t report errors. strtol tells you where parsing stopped and whether the value overflowed:

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

void parse(const char *input) {
    char *end;
    errno = 0;
    long val = strtol(input, &end, 10);
    if (end == input) {
        printf("%-14s -> no digits at all\n", input);
    } else if (errno == ERANGE) {
        printf("%-14s -> out of range, clamped to %ld\n", input, val);
    } else {
        printf("%-14s -> %ld, leftover \"%s\"\n", input, val, end);
    }
}

int main(void) {
    printf("atoi(\"42\") = %d, atoi(\"abc\") = %d\n", atoi("42"), atoi("abc"));
    parse("  -123abc");
    parse("abc");
    parse("99999999999999999999");
    printf("hex 0xFF = %ld, octal 0777 = %ld, auto 0x1F = %ld\n",
           strtol("0xFF", NULL, 16), strtol("0777", NULL, 8), strtol("0x1F", NULL, 0));
    char *end;
    double f = strtod("3.14xyz", &end);
    printf("strtod: %.2f, leftover \"%s\"\n", f, end);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o convert convert.c
$ ./convert
atoi("42") = 42, atoi("abc") = 0
  -123abc      -> -123, leftover "abc"
abc            -> no digits at all
99999999999999999999 -> out of range, clamped to 9223372036854775807
hex 0xFF = 255, octal 0777 = 511, auto 0x1F = 31
strtod: 3.14, leftover "xyz"

atoi("abc") returns 0, indistinguishable from a real "0". strtol separates all three failure shapes. If end still equals input, no digits were found. ERANGE means overflow, and the value is clamped to LONG_MAX. A non-empty leftover means trailing junk. The “no digits” check must compare against the pointer you actually passed in: comparing end to a second copy of the literal, as in end == "abc", compares two unrelated addresses. Base 0 detects 0x (hex) and leading 0 (octal) automatically.

Joining Strings

To assemble a string from parts, measure first, allocate once, then fill:

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

/* Join strings with a separator; the caller must free() the result */
char *join_strings(const char **parts, size_t count, const char *sep) {
    size_t total = 1;                        /* room for '\0' */
    for (size_t i = 0; i < count; i++) {
        total += strlen(parts[i]);
        if (i + 1 < count) total += strlen(sep);
    }
    char *result = malloc(total);
    if (!result) return NULL;

    char *p = result;
    for (size_t i = 0; i < count; i++) {
        p = stpcpy(p, parts[i]);             /* returns pointer to the new '\0' */
        if (i + 1 < count) p = stpcpy(p, sep);
    }
    return result;
}

int main(void) {
    const char *words[] = {"the", "quick", "brown", "fox"};
    char *sentence = join_strings(words, 4, " ");
    if (!sentence) return 1;
    printf("\"%s\" (%zu chars)\n", sentence, strlen(sentence));
    free(sentence);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -D_POSIX_C_SOURCE=200809L -o join join.c
$ ./join
"the quick brown fox" (19 chars)

A loop of strcat calls would work, but every strcat rescans the whole result from the start to find its end. That makes joining n parts roughly O(n²). stpcpy (POSIX, and standard C from C23) returns a pointer to the end of what it just wrote, so each part is copied exactly once. -D_POSIX_C_SOURCE=200809L makes stpcpy visible in strict -std=c17 mode.

Common String Mistakes

Each of these compiles. Some trigger warnings, all of them are undefined behavior, and the results can look deceptively normal.

Forgetting the terminator. printf and strlen keep reading until they happen to find a zero byte:

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

int main(void) {
    char good[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
    char bad[5]  = {'H', 'e', 'l', 'l', 'o'};   /* no room for '\0' */
    printf("good: %s (strlen %zu)\n", good, strlen(good));
    printf("bad:  %s (strlen %zu)\n", bad, strlen(bad));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o noterm noterm.c
$ ./noterm
good: Hello (strlen 5)
bad:  HelloHello (strlen 10)
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address -o noterm noterm.c
$ ./noterm
=================================================================
==961==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f26a0400025 at pc 0x7f26a26cee66 bp 0x7ffd464104d0 sp 0x7ffd4640fc90
READ of size 6 at 0x7f26a0400025 thread T0

bad happened to sit right before good in memory, so reading past its end ran into good’s characters and stopped at good’s terminator. The result was a doubled word and a length of 10 for a 5-byte array. No warning was printed. AddressSanitizer catches the overread immediately.

Returning a pointer to a local array. The array is destroyed when the function returns:

#include <stdio.h>

const char *make_label(void) {
    char local[] = "temporary";
    return local;              /* local dies when the function returns */
}

int main(void) {
    const char *label = make_label();
    printf("label = %s\n", label ? label : "(null)");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o dangling dangling.c
dangling.c: In function 'make_label':
dangling.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
    5 |     return local;              /* local dies when the function returns */
      |            ^~~~~
$ ./dangling
label = (null)

GCC warns, and then goes a step further: knowing the pointer is useless, it compiles the return as returning NULL. That’s why the output says (null) rather than garbage. Other compilers hand back the dangling address, which prints leftover stack contents until something overwrites them. The fix is to return a string literal, fill a buffer the caller passes in, or malloc a copy.

Off-by-one buffer size. "Hello" needs six bytes, not five:

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

int main(void) {
    char buf[5];
    strcpy(buf, "Hello");      /* needs 6 bytes: H e l l o \0 */
    printf("%s\n", buf);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -O2 -o overflow overflow.c
overflow.c: In function 'main':
overflow.c:6:5: warning: '__builtin_memcpy' forming offset 5 is out of the bounds [0, 5] of object 'buf' with type 'char[5]' [-Warray-bounds=]
    6 |     strcpy(buf, "Hello");      /* needs 6 bytes: H e l l o \0 */
      |     ^~~~~~~~~~~~~~~~~~~~
overflow.c:5:10: note: 'buf' declared here
    5 |     char buf[5];
      |          ^~~
$ ./overflow
Hello

The program printed Hello and exited normally, even though the terminator was written one byte past buf onto whatever lives next to it. At -O2, GCC could see the fixed sizes and warned. With a runtime-length source it can’t, and this exact pattern is the classic stack buffer overflow exploited by attackers (see the security lesson).

Practice

  1. Write my_strlen using pointer arithmetic, and compare it with strlen on "", "a" and "Hello, World!".
Solution
#include <stdio.h>
#include <string.h>

size_t my_strlen(const char *s) {
    const char *p = s;
    while (*p != '\0') p++;
    return (size_t)(p - s);
}

int main(void) {
    const char *tests[] = {"", "a", "Hello, World!"};
    for (int i = 0; i < 3; i++) {
        printf("\"%s\": mine=%zu, strlen=%zu\n", tests[i], my_strlen(tests[i]), strlen(tests[i]));
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
"": mine=0, strlen=0
"a": mine=1, strlen=1
"Hello, World!": mine=13, strlen=13

Walk a pointer to the terminator and subtract the start. Every call rescans the string, so strlen is O(n). Calling it in a loop condition, as in for (i = 0; i < strlen(s); i++), quietly makes the loop O(n²).

  1. Reverse a string in place. "stressed" should become "desserts".
Solution
#include <stdio.h>
#include <string.h>

void reverse_in_place(char *s) {
    size_t n = strlen(s);
    if (n < 2) return;
    for (size_t i = 0, j = n - 1; i < j; i++, j--) {
        char tmp = s[i];
        s[i] = s[j];
        s[j] = tmp;
    }
}

int main(void) {
    char a[] = "stressed";
    char b[] = "C";
    reverse_in_place(a);
    reverse_in_place(b);
    printf("%s %s\n", a, b);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
desserts C

The strings must be char a[] = ... (writable copies). Passing a literal such as reverse_in_place("stressed") would try to write to read-only memory and crash. The n < 2 guard also protects n - 1 from wrapping around on an empty string.

  1. Count the words in a string, treating any run of whitespace as a single separator.
Solution
#include <stdio.h>
#include <ctype.h>

int count_words(const char *s) {
    int count = 0, in_word = 0;
    for (; *s; s++) {
        if (isspace((unsigned char)*s)) {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            count++;
        }
    }
    return count;
}

int main(void) {
    printf("%d\n", count_words("the quick brown fox"));
    printf("%d\n", count_words("   leading and   extra   spaces  "));
    printf("%d\n", count_words(""));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
4
4
0

Count the start of each word, the transition from space to non-space, rather than the spaces themselves. That’s what makes extra spaces harmless. The (unsigned char) cast matters: <ctype.h> functions are undefined for negative values, which a plain char holding a non-ASCII byte can be.

  1. From the path /home/user/docs/report.final.txt, extract the file name and its extension.
Solution
#include <stdio.h>
#include <string.h>

int main(void) {
    char path[] = "/home/user/docs/report.final.txt";
    char *slash = strrchr(path, '/');
    char *file = slash ? slash + 1 : path;
    char *dot = strrchr(file, '.');
    printf("file: %s\n", file);
    printf("extension: %s\n", dot ? dot + 1 : "(none)");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
file: report.final.txt
extension: txt

strrchr (search from the right) finds the last separator in both cases. strchr would stop at the first . and report final.txt as the extension. No copying was needed: file and dot + 1 are pointers into the original path.

Frequently Asked Questions

Why are C strings null-terminated?
C has no built-in string type. A string is just an array of chars where a '\0' byte marks the end, and functions like printf and strlen walk the bytes until they reach it. Forget the terminator and those functions keep reading past the end of your array into whatever memory follows.
What is the difference between a string literal and a char array?
A string literal like "hello" is stored in read-only memory, and writing to it is undefined behavior that usually crashes. char s[] = "hello" copies the literal into a writable array that you own and can modify. Point at literals with const char * so the compiler rejects accidental writes.
Is strncpy a safe replacement for strcpy?
Not by itself. strncpy stops after n bytes, but if the source is that long it leaves the destination without a '\0', so you must terminate it yourself. snprintf(dst, size, "%s", src) is usually the simpler safe copy: it never writes past size bytes, always terminates, and returns the length needed so you can detect truncation.
Why use strtol instead of atoi?
atoi returns 0 both for the input "0" and for input with no digits at all, and has undefined behavior on overflow, so errors are invisible. strtol reports where parsing stopped through its end pointer and sets errno to ERANGE on overflow, which lets you reject bad input.