Arrays and Strings in C: Declaration, Manipulation, and Best Practices

Arrays and Strings in C

Arrays and strings were some of the first real “data structures” I ever worked with when I was learning C, and honestly, they taught me more about how memory actually works than almost anything else in the language. Coming from higher-level languages where strings are objects with built-in methods, I remember being genuinely surprised the first time I found out that a C string is really just an array of characters with a \0 sitting at the end, marking where it stops.

In this article, I’m going to walk through arrays and strings in C from the ground up — how they’re declared, how they behave in memory, how to manipulate them safely, and where people (myself included) commonly go wrong. I’ll show you complete programs with output at every step, and I’ll dig into the internal memory behavior so you understand not just what to write, but why it works the way it does.

What Is an Array in C?

An array is a fixed-size, contiguous block of memory that stores multiple elements of the same data type. “Contiguous” is the key word here — every element sits right next to the previous one in memory, which is what makes array indexing so fast.

#include <stdio.h>

int main() {
    int numbers[5] = {10, 20, 30, 40, 50};

    for (int i = 0; i < 5; i++) {
        printf("numbers[%d] = %d, address = %p\n", i, numbers[i], (void*)&numbers[i]);
    }

    return 0;
}

Output:

numbers[0] = 10, address = 0x7ffee1a2b9a0
numbers[1] = 20, address = 0x7ffee1a2b9a4
numbers[2] = 30, address = 0x7ffee1a2b9a8
numbers[3] = 40, address = 0x7ffee1a2b9ac
numbers[4] = 50, address = 0x7ffee1a2b9b0

Notice the addresses increase by exactly 4 bytes each time — that’s sizeof(int). This contiguous layout is exactly why arr[i] works the way it does: the compiler computes the address as base_address + (i * sizeof(type)).

Array Declaration Syntax

datatype array_name[size];
datatype array_name[size] = {value1, value2, ..., valueN};
datatype array_name[] = {value1, value2, ..., valueN}; // size inferred

I almost always let the compiler infer the size when I’m initializing with a literal list — it’s one less thing to keep in sync manually if I add or remove elements later.

int scores[] = {85, 92, 78, 90}; // size automatically becomes 4

If you declare an array without initializing it, the values are indeterminate (garbage) for local arrays, but global/static arrays are automatically zero-initialized.

#include <stdio.h>

int globalArr[5]; // zero-initialized automatically

int main() {
    int localArr[5]; // contains garbage values

    printf("Global: %d\n", globalArr[0]);
    printf("Local (garbage): %d\n", localArr[0]);

    return 0;
}

Multidimensional Arrays

C supports multidimensional arrays, which are stored in row-major order — meaning the entire first row is stored contiguously, followed by the entire second row, and so on.

#include <stdio.h>

int main() {
    int matrix[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };

    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 3; j++) {
            printf("%d ", matrix[i][j]);
        }
        printf("\n");
    }

    return 0;
}

Output:

1 2 3
4 5 6

Understanding row-major order matters for performance — iterating row by row (as I did above) accesses memory sequentially, which is cache-friendly. Iterating column by column jumps around in memory and can be noticeably slower for large matrices.

Arrays and Pointers: The Decay Relationship

This is a concept that confused me for a long time until I really sat with it: in most expressions, an array name decays into a pointer to its first element. This is why you can do this:

#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int *p = arr; // no & needed — arr decays to &arr[0]

    printf("*p = %d\n", *p);
    printf("*(p+2) = %d\n", *(p + 2));
    printf("arr[2] = %d\n", arr[2]);

    return 0;
}

Output:

*p = 1
*(p+2) = 3
arr[2] = 3

Interestingly, arr[i] is actually defined as *(arr + i) in the C standard — array indexing is literally pointer arithmetic in disguise. That’s also why 2[arr] is valid, bizarre-looking C, and equivalent to arr[2] — addition is commutative.

But I want to be clear about an important distinction: an array is not a pointer. sizeof(arr) gives you the total size of the array in bytes, while sizeof(p) gives you the size of a pointer (usually 8 bytes on a 64-bit system).

printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20 (5 ints * 4 bytes)
printf("sizeof(p) = %zu\n", sizeof(p));     // 8 (pointer size)

Passing Arrays to Functions

When you pass an array to a function, it decays into a pointer — the function never actually receives a copy of the whole array.

#include <stdio.h>

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main() {
    int nums[4] = {1, 2, 3, 4};
    printArray(nums, 4);
    return 0;
}

Output:

1 2 3 4

This is why you always need to pass the array’s size as a separate parameter — the function has no way to know how big the original array was, since all it really receives is a pointer.

What Is a String in C?

A C string is a sequence of characters terminated by a null character \0. There’s no separate string type — strings are just char arrays with this special terminator marking the end.

#include <stdio.h>

int main() {
    char greeting[] = "Hello";

    for (int i = 0; greeting[i] != '\0'; i++) {
        printf("greeting[%d] = %c\n", i, greeting[i]);
    }

    return 0;
}

Output:

greeting[0] = H
greeting[1] = e
greeting[2] = l
greeting[3] = l
greeting[4] = o

Even though I only wrote 5 characters, sizeof(greeting) is actually 6 — the compiler silently adds the \0 terminator. This is something I always double check when I’m allocating buffers by hand, because forgetting to account for the null terminator is an extremely common source of off-by-one bugs.

String Declaration Methods

char str1[6] = "Hello";              // array with explicit size
char str2[] = "Hello";               // size inferred (6, including \0)
char str3[6] = {'H','e','l','l','o','\0'}; // manual character array
char *str4 = "Hello";                // pointer to a string literal

I want to flag something important about str4: string literals assigned to a char* are stored in read-only memory. Trying to modify them causes undefined behavior — often a segmentation fault.

char *str4 = "Hello";
str4[0] = 'J'; // undefined behavior — may crash

If you need a modifiable string, always use a char array, not a pointer to a literal:

char str5[] = "Hello";
str5[0] = 'J'; // perfectly fine — this is a local, writable copy

Common String Functions (<string.h>)

I use these constantly, so it’s worth knowing them well:

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

int main() {
    char s1[20] = "Hello";
    char s2[] = "World";

    printf("Length of s1: %zu\n", strlen(s1));

    strcat(s1, " ");
    strcat(s1, s2);
    printf("After strcat: %s\n", s1);

    char s3[20];
    strcpy(s3, s1);
    printf("Copied string: %s\n", s3);

    printf("Comparison: %d\n", strcmp(s1, s3));

    char *found = strstr(s1, "World");
    printf("Substring found at: %s\n", found);

    return 0;
}

Output:

Length of s1: 5
After strcat: Hello World
Copied string: Hello World
Comparison: 0
Substring found at: World
  • strlen() — returns the length excluding the null terminator.
  • strcpy() — copies one string into another; the destination must have enough space.
  • strcat() — appends one string to another; again, destination needs enough room.
  • strcmp() — returns 0 if equal, negative if the first string is lexicographically smaller, positive if larger.
  • strstr() — finds a substring and returns a pointer to its first occurrence, or NULL if not found.

The Danger of Unsafe String Functions

I need to be direct about something here: strcpy(), strcat(), and gets() (which is removed entirely from modern C standards) don’t perform bounds checking. If the destination buffer is too small, they’ll happily write past its end, corrupting adjacent memory. This is one of the most historically exploited categories of security vulnerabilities in C programs — the classic buffer overflow.

char small[5];
strcpy(small, "This string is way too long"); // buffer overflow — undefined behavior

I strongly recommend using the safer, bounded alternatives whenever they’re available:

#include <string.h>

char dest[10];
strncpy(dest, "Hello, World", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // strncpy doesn't guarantee null-termination

strncpy limits how many bytes it copies, but there’s a subtlety I want to highlight: if the source string is longer than the limit, strncpy does not null-terminate the destination automatically — so you should always manually add the terminator yourself, as I did above.

Reading Strings Safely from Input

I avoid gets() entirely — it’s been removed from the C11 standard because it has no way to limit how much it reads, making it inherently unsafe. Instead, I use fgets():

#include <stdio.h>

int main() {
    char buffer[50];
    printf("Enter your name: ");
    fgets(buffer, sizeof(buffer), stdin);

    // fgets keeps the newline character, so I strip it manually
    size_t len = strlen(buffer);
    if (len > 0 && buffer[len - 1] == '\n') {
        buffer[len - 1] = '\0';
    }

    printf("Hello, %s!\n", buffer);
    return 0;
}

Sample Input: Claude

Output:

Enter your name: Hello, Claude!

Manual String Manipulation (Writing Your Own Functions)

Understanding how the standard library functions work internally really deepened my grasp of pointers and strings. Here’s a hand-written strlen:

#include <stdio.h>

int my_strlen(const char *str) {
    int count = 0;
    while (str[count] != '\0') {
        count++;
    }
    return count;
}

int main() {
    printf("Length: %d\n", my_strlen("Programming in C"));
    return 0;
}

Output:

Length: 17

And here’s a hand-written string reversal, which is a fairly common interview exercise:

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

void reverseString(char *str) {
    int left = 0;
    int right = strlen(str) - 1;

    while (left < right) {
        char temp = str[left];
        str[left] = str[right];
        str[right] = temp;
        left++;
        right--;
    }
}

int main() {
    char text[] = "Hello";
    reverseString(text);
    printf("Reversed: %s\n", text);
    return 0;
}

Output:

Reversed: olleH

I like this example because it also demonstrates in-place manipulation using two pointers (well, in this case, two indices) — a technique that comes up constantly in algorithm problems.

Internal Memory Behavior: Compile Time vs Runtime

At compile time, the compiler determines the size of fixed arrays and reserves stack space accordingly (for local arrays) or allocates space in the data/BSS segment (for global/static arrays). String literals like "Hello" are placed in a read-only section of memory, often called .rodata.

At runtime:

  • Local arrays live on the stack, and their memory is reclaimed automatically when the function returns.
  • Arrays created with malloc/calloc live on the heap, and persist until explicitly freed.
  • Global and static arrays live in the data segment (if initialized) or BSS segment (if zero/uninitialized), and persist for the entire program’s lifetime.
#include <stdio.h>
#include <stdlib.h>

int global_arr[100];              // BSS segment (zero-initialized)
int global_initialized[3] = {1,2,3}; // Data segment

void demo() {
    int local_arr[10];             // Stack
    int *heap_arr = malloc(10 * sizeof(int)); // Heap
    free(heap_arr);
}

int main() {
    demo();
    return 0;
}

Dynamic Arrays and Strings

Since C arrays are fixed-size, I often need dynamic sizing — this is where malloc and realloc come in.

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

int main() {
    int n;
    printf("How many numbers? ");
    scanf("%d", &n);

    int *arr = (int*) malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("Allocation failed\n");
        return 1;
    }

    for (int i = 0; i < n; i++) {
        arr[i] = i * 10;
    }

    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    free(arr);
    return 0;
}

Sample Input: 4

Output:

How many numbers? 0 10 20 30

For dynamic strings, I usually allocate strlen(source) + 1 bytes to account for the null terminator:

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

int main() {
    const char *source = "Dynamic Strings";
    char *copy = (char*) malloc(strlen(source) + 1);

    if (copy == NULL) {
        return 1;
    }

    strcpy(copy, source);
    printf("Copy: %s\n", copy);

    free(copy);
    return 0;
}

Output:

Copy: Dynamic Strings

Common Mistakes with Arrays and Strings

  1. Off-by-one errors — forgetting to reserve space for the null terminator, or looping one index too far (i <= size instead of i < size).
  2. Buffer overflows — writing beyond the bounds of an array using unsafe functions like strcpy.
  3. Comparing strings with == — this compares pointer addresses, not content. You must use strcmp().
char *a = "test";
char *b = "test";
if (a == b) { /* may or may not be true depending on compiler string interning */ }
if (strcmp(a, b) == 0) { /* this is the correct way to compare content */ }
  1. Modifying string literals — as shown earlier, this is undefined behavior.
  2. Returning pointers to local arrays — the array is destroyed once the function returns, leaving a dangling pointer.
  3. Forgetting arrays don’t carry their size — always pass the size explicitly to functions.

Best Practices

  • Always account for the null terminator when sizing character buffers.
  • Prefer fgets() over gets() or unchecked scanf("%s", ...).
  • Use strncpy/snprintf and always manually null-terminate afterward.
  • Pass array sizes explicitly to functions — never assume the function can infer it.
  • Use const char * for function parameters that shouldn’t modify the input string.
  • Validate malloc/realloc return values before use.
  • When comparing strings, always use strcmp(), never ==.
  • Free every dynamically allocated string/array exactly once.

Performance Optimization Tips

  • Iterate multidimensional arrays in row-major order to maximize cache locality.
  • Avoid unnecessary string copies — pass strings by const char* reference instead of duplicating them when you don’t need to modify them.
  • Prefer memcpy() over manual byte-by-byte copying loops for large blocks — it’s typically highly optimized by the compiler/library.
  • When building a string incrementally (e.g., in a loop), avoid repeated strcat() calls, which re-scan the string from the start each time — this becomes O(n²). Instead, track an end pointer/index and append directly.
#include <stdio.h>
#include <string.h>

int main() {
    char buffer[100] = "";
    char *end = buffer;

    const char *words[] = {"C ", "is ", "fast ", "and ", "powerful"};
    for (int i = 0; i < 5; i++) {
        strcpy(end, words[i]);
        end += strlen(words[i]);
    }

    printf("%s\n", buffer);
    return 0;
}

Output:

C is fast and powerful

Real-World Applications

  • Text processing tools: parsers, tokenizers, and compilers all rely heavily on string manipulation.
  • Data buffers: network programming and file I/O often use byte arrays as buffers.
  • Matrix operations: multidimensional arrays underpin graphics, scientific computing, and machine learning libraries written in C.
  • Embedded systems: fixed-size arrays are preferred in memory-constrained environments where dynamic allocation is risky or disallowed.
  • Command-line argument parsing: argv in main(int argc, char *argv[]) is literally an array of C strings.
#include <stdio.h>

int main(int argc, char *argv[]) {
    for (int i = 0; i < argc; i++) {
        printf("Argument %d: %s\n", i, argv[i]);
    }
    return 0;
}

Common Interview Questions

  1. What is the difference between an array and a pointer in C? An array is a fixed block of contiguous memory with a size known at compile time (via sizeof); a pointer is a variable holding an address and knows nothing about the size of what it points to.
  2. Why is a C string terminated with \0? Because C strings don’t store their own length — functions rely on scanning until they hit the null terminator to know where the string ends.
  3. What’s wrong with using strcpy() on user input? It performs no bounds checking, so it can overflow the destination buffer if the input is longer than expected.
  4. How would you reverse a string in-place without extra memory? Use the two-pointer/two-index swap technique shown earlier — O(n) time, O(1) extra space.
  5. What is row-major order, and why does it matter? It’s how C stores multidimensional arrays — row by row in memory. It matters for cache performance when iterating large matrices.
  6. How do you dynamically allocate a 2D array in C? Typically via an array of pointers, each pointing to a dynamically allocated row, or via a single contiguous block with manual index calculation.
int **matrix = malloc(rows * sizeof(int*));
for (int i = 0; i < rows; i++) {
    matrix[i] = malloc(cols * sizeof(int));
}

Frequently Asked Questions

Q: Can array size be a variable in C? Since C99, yes — this is called a Variable Length Array (VLA): int arr[n]; where n is a runtime variable. However, VLAs live on the stack, so large sizes risk stack overflow, and I generally prefer malloc for anything beyond a small, bounded size.

Q: Is char str[] = "Hi"; the same as char *str = "Hi";? No. The array version creates a modifiable local copy of “Hi” on the stack. The pointer version points to a read-only string literal, which you should never try to modify.

Q: Why does sizeof behave differently on arrays passed to functions? Because the array decays into a pointer as a function parameter, sizeof on that parameter gives the pointer’s size, not the array’s original size — a very common source of confusion.

Q: What’s the difference between strlen() and sizeof() on a string? strlen() counts characters up to (but not including) \0 at runtime; sizeof() gives the total allocated size of the array, known at compile time, including the terminator (if applied directly to an array, not a pointer).

Troubleshooting Tips

  • If printf("%s", str) prints garbage or crashes, check whether str is properly null-terminated.
  • If you see corrupted values in unrelated variables, suspect a buffer overflow — run AddressSanitizer to pinpoint it.
  • If string comparisons behave unexpectedly, verify you’re using strcmp() and not ==.
  • If a function modifying a string literal crashes, switch it to a proper writable char array.

Summary and Key Takeaways

Arrays and strings in C are deceptively simple on the surface but reveal a lot about how memory really works once you dig in. Here’s what I hope stays with you:

  • Arrays are contiguous blocks of memory, and indexing is really pointer arithmetic underneath.
  • C strings are char arrays terminated by \0 — there’s no built-in string type.
  • Array names decay to pointers in most expressions, but arrays and pointers are not the same thing.
  • Unsafe functions like strcpy, strcat, and gets are common sources of buffer overflows — prefer their bounded, safer counterparts.
  • Understanding stack vs heap vs data/BSS segment placement clarifies why certain bugs (like dangling pointers from local arrays) happen.
  • Good discipline — checking bounds, null-terminating manually, validating allocations — prevents the overwhelming majority of array and string bugs.

Once these concepts settle in, you’ll find that a huge portion of C programming — parsing, buffers, matrices, text processing — is really just applied array and string manipulation.

References

Total
1
Shares

Leave a Reply

Previous Post
Function Declaration and Definition in C

Function Declaration and Definition in C: Prototypes, Parameters, and Return Types

Next Post
Pointers and Memory Management in C

Pointers and Memory Management in C: Dynamic Allocation and Optimization

Related Posts