Skip to content

C Pitfalls and Common Errors ​

Honestly, when we were learning C, the pitfalls we stepped in outnumbered the lines of correct code we wrote. C's design philosophy is "trust the programmer"—the compiler will not stop you from doing something stupid; it will quietly compile that stupid thing into machine code and then watch you segfault. Many design decisions from the K&R era look positively ancient today, but for the sake of backward compatibility these traps have been handed down generation after generation, becoming a required course for every C/C++ programmer.

In this article we will systematically walk through the traps C sets most often—not a vague "be careful out there", but a real understanding from the angles of compiler behavior, the language standard, and the underlying mechanics: why does this go wrong? How does the compiler actually read it? Once you have these figured out, you will find that many seemingly inexplicable bugs actually follow a traceable pattern—and that C++ features were not invented out of thin air: every single one is a hard-won lesson from someone stepping into a pit before you.

Every code example in this article compiles and runs in a standard C environment. To show what compiler warnings can catch, we recommend always compiling with -Wall -Wextra—you will find that many of these traps are actually detectable through modern compiler warnings, provided you have not ignored those warnings.

text
Platform: Linux / macOS / Windows (MSVC/MinGW)
Compiler: GCC >= 9 or Clang >= 12
Standard: -std=c11 (C parts) / -std=c++17 (C++ comparison parts)
Dependencies: none

Step 1 — Understand How the Compiler "Reads" Your Code ​

Let's start with the most basic question of all: how does the compiler slice your source code into individual tokens? This seemingly boring question is precisely the root of many bizarre bugs.

The "Maximal Munch" Principle ​

C's lexical analyzer follows the "maximal munch" principle—it always tries to read in as many characters as possible to form a valid token. This rule works well in most cases, but in certain edge cases it produces surprising results:

c
int a = 5;
int b = a+++b;  // How does this get parsed, exactly?

Your intuition might say a + (++b), but the compiler actually parses it as (a++) + b. As the lexer scans from left to right, it first grabs a++ (a legal postfix increment), and the remaining +b becomes an addition. The compiler does not "look back" to consider a + (++b)—it just keeps greedily munching forward.

Compile and run, then look at the warning:

text
$ gcc -Wall -std=c11 max_munch.c -o max_munch
max_munch.c:2:14: warning: operation on 'a' may be undefined [-Wsequence-point]

Writing several + or - characters in a row is legal, but extremely easy to misread. Whenever you are not sure, add parentheses—parentheses not only remove the ambiguity, they also make the intent of the code clearer. It is a zero-cost insurance policy.

A Comment That Swallows the Division Sign ​

Here is an even sneakier example:

c
int x = 10;
int* p = &x;
int result = x/*p;  // The intent was x / (*p)

The code intends to divide x by the value of *p. But per greedy matching, /* is parsed as the start of a comment, so x/*p; becomes x followed by a comment that never ends. If your source file is fairly large, this comment may swallow several lines of code after it, and you will just be puzzled by "why are all the variables below undefined?"

c
// Correct version: use parentheses or an intermediate variable to remove the ambiguity
int result = x / (*p);     // The parentheses block the greedy match
int divisor = *p;
int result = x / divisor;  // Clearer

Step 2 — Dodge the Hidden Traps of Operator Precedence ​

C has 15 precedence levels and dozens of operators—honestly, nobody can remember them all while writing code. But some precedence relationships clash badly with intuition: the code looks fine on the surface while secretly doing something completely different.

Bitwise vs. Comparison Operators ​

This is, in our opinion, the most sinister precedence trap of all:

c
// Check whether bit 3 of flags is set
if (flags & 0x04 == 0) {
    // The intent was (flags & 0x04) == 0
    // It actually parses as flags & (0x04 == 0)
    // i.e. flags & 0, which is always 0!
}

Because == has higher precedence than &—yes, bitwise AND really does bind more loosely than equality comparison. In flags & 0x04 == 0, the compiler first evaluates 0x04 == 0 (which is 0), then flags & 0 (which is 0), so the condition is always true. What makes this bug especially insidious is that whether or not bit 3 of flags is set, the outcome is identical—you can never catch it through testing.

c
// Correct version
if ((flags & 0x04) == 0) {
    // Now we really are checking bit 3
}

Undefined Behavior in Pointer Arithmetic ​

c
int values[5] = {10, 20, 30, 40, 50};
int* p = values;
int product = *p * *p++;  // Undefined behavior!

This code has a double problem. In *p++, because postfix ++ binds tighter than the dereference *, it actually means *(p++)—take the value first, then increment, which is roughly what you would expect. But the second problem is the real disaster: reading and writing the same variable p within one expression is undefined behavior under the C standard, and the compiler is legally allowed to produce any result.

c
// Correct version: split the operations apart
int val = *p;
int product = val * val;
p++;

Whenever bitwise operations are involved, add parentheses across the board. When in doubt, add parentheses—the compiler will not mock you for writing an extra pair. Remember the key counterintuitive points: the bitwise operators (&, |, ^) sit below the comparison operators in precedence, and the assignment operators are almost the lowest of all (only the comma operator is lower).

Step 3 — Stop Mixing Up = and == ​

Nearly every C/C++ programmer has fallen into this pit—confusing = and ==. Including the author of this article.

Assignment Inside an if ​

c
int x = 0;
int y = 42;
if (x = y) {
    printf("x equals y\n");  // Always executes!
}

x = y is an assignment expression—it assigns the value of y to x, and the value of the whole expression is x after the assignment (that is, 42). Since 42 is nonzero, the condition is true. The printf always executes, and along the way x has been quietly changed to 42. This bug causes no compile error and no runtime crash—it just silently changes your program's logic, which makes it a huge headache to track down.

Fortunately, modern compilers issue a warning:

text
$ gcc -Wall -std=c11 assign_vs_eq.c -o assign_vs_eq
assign_vs_eq.c:3:9: warning: using the result of an assignment as a condition [-Wparentheses]

A Pileup in a while Loop ​

c
int c;
while (c = ' ' || c == '\t' || c == '\n') {
    c = getchar();
}

The intent is to skip whitespace characters in the input. But c = ' ' is an assignment, not a comparison: ' ' (ASCII 32) is nonzero, so after || short-circuit evaluation the whole expression is 1 (true), and c gets assigned 1—an infinite loop.

c
// Correct version
#include <ctype.h>
int c;
while ((c = getchar()) != EOF && isspace(c)) {
    // Skip whitespace characters
}

Defensive Style: Put the Constant on the Left ​

There is a classic defensive trick—put the constant on the left of the comparison operator:

c
if (42 = x) { /* Compile error! You cannot assign to a constant */ }

If your fingers slip and write = instead of ==, the compiler will immediately report an error, because 42 is not an lvalue. This trick does feel a bit awkward to write (it reads like "if 42 equals x"), but it works. The better practice, though: always enable -Wall -Wextra, and treat warnings as errors (-Werror).

Step 4 — Beware the Subtle Traps of the Semicolon ​

The semicolon is a statement terminator—it looks as simple as syntax can get. But with this little character, one too many will not do, and one too few will not do either; both mistakes lead to extremely weird bugs.

The Extra Semicolon: A Silent Logic Error ​

c
int max_value(int* x, int n)
{
    int big = x[0];
    for (int i = 1; i < n; i++)
        if (x[i] > big);   // ← This semicolon turns the if body into an empty statement!
            big = x[i];     // Executes unconditionally
    return big;
}

The semicolon after the if condition turns the if body into an empty statement, so big = x[i] no longer belongs to the if—it executes unconditionally. In the end big equals the last element, not the maximum. This bug does not crash, does not report an error, and can even return the "correct" result for an ascending array. We tested it in practice: one counterexample exposes it—

text
Input: {50, 20, 30, 10, 40}
Expected output: 50
Actual output: 40 (the last element, not the maximum)
c
// Correct version: always use braces
int max_value(int* x, int n)
{
    int big = x[0];
    for (int i = 1; i < n; i++) {
        if (x[i] > big) {
            big = x[i];
        }
    }
    return big;
}

When a control statement (if, while, for) governs a single statement, many people omit the braces. That is fine in itself—but if your fingers slip and add a semicolon after the condition, the control statement's body becomes an empty statement. Getting into the habit of always using braces eliminates this entire class of problems.

The Missing Semicolon: A Chain Reaction of Errors ​

The other way around, a missing semicolon causes just as much trouble, and the error message usually points to the "wrong location":

c
extern int count
                     // ← Missing semicolon
void process(void) { // The compiler reports the error here!
    count++;
}

The compiler treats the newline after count as a continuation of the declaration and keeps expecting a semicolon, so it reports the error at void process(void) on the next line—this kind of mismatch between the reported error location and the actual error location is especially confusing for beginners.

Step 5 — See Through the Ambiguity Between Declarations and Expressions ​

C's declaration syntax is complicated enough on its own, but in certain situations a legal declaration and a legal expression look almost identical.

The "Most Vexing Parse" ​

c
int x();  // Is this a variable or a function?

If your intuition says "an int variable x initialized to a default value", you have stepped into the trap. Under C's grammar rules, int x() is parsed as a function declaration—a function named x, taking no parameters, returning int. In C++ this ambiguity is even more serious:

C++
class Timer {
public:
    Timer() {}
};

Timer t();  // A function declaration! Returns Timer, takes no parameters
            // Not a variable t of type Timer

Later, if you write t.something(), the compiler will give you a baffled look and tell you "t is a function and cannot be used like this".

Function Pointer Declarations — Simplify Them with typedef ​

C's function pointer declaration syntax is famously hard to read. Take a look at the real declaration of the signal function:

c
void (*signal(int sig, void (*func)(int)))(int);

The first time we saw this declaration, only three words formed in our heads: what is this? The structure is return_type (*function_name(parameter_list))(parameter_list)—because the return value is a function pointer, the return type has to "sandwich" the function name in the middle. Readability is essentially zero. The right approach is to simplify it with typedef:

c
typedef void (*SignalHandler)(int);
// Much clearer now
SignalHandler signal(int sig, SignalHandler func);

The Right-Left Rule ​

There is a classic technique called the "Right-Left Rule" for decoding complex C declarations. Start from the variable name, read to the right first; when you hit parentheses, turn and read to the left; when you hit an opening parenthesis, jump out and continue to the right:

c
int (*arr)[10];
// arr → left: * (pointer) → right: [10] (10-element array) → left: int
// Conclusion: a pointer to an array of 10 ints

int (*func_array[5])(double);
// func_array → right: [5] (5-element array) → left: * (pointer)
// → right: (double) (function taking a double) → left: int (returns int)
// Conclusion: a 5-element array of function pointers, each pointing to an int(double) function

Although the Right-Left Rule can help you decode complex declarations, in actual coding please use typedef to simplify them as much as possible. Do not show off by writing a declaration that takes half a minute to read—you will feel clever writing it today, and three months later even you will not be able to read it.

Step 6 — Common Errors at the Semantic Level ​

The previous sections were all traps at the syntactic level; this section adds a few classic semantic errors—the compiler will not stop you, but your program is simply wrong.

Array Out-of-Bounds Access ​

C performs no array bounds checking. This is a design-philosophy choice—bounds checking has runtime overhead, and C leaves safety to the programmer's own responsibility:

c
int arr[5] = {1, 2, 3, 4, 5};
for (int i = 0; i <= 5; i++) {  // Out of bounds when i=5!
    printf("%d\n", arr[i]);
}

arr has 5 elements, with valid indices 0 through 4. When i = 5, arr[5] accesses memory past the end of the array—reading it is undefined, and writing it is even more dangerous: it can overwrite other variables, corrupt the stack frame, cause a segfault, or even become a security vulnerability (the basic mechanism of buffer overflow attacks is precisely deliberate out-of-bounds writes).

c
// Correct version: compute the array size with sizeof, so it adapts automatically if the length changes
int arr[] = {1, 2, 3, 4, 5};
int len = sizeof(arr) / sizeof(arr[0]);
for (int i = 0; i < len; i++) {
    printf("%d\n", arr[i]);
}

Uninitialized Variables ​

In C, local variables are not automatically initialized to zero—their initial value is whatever garbage happens to remain in that stack memory, and it may differ from run to run:

c
int count;  // Uninitialized
if (some_condition) {
    count = 0;
}
// If some_condition is false, count is garbage
printf("count = %d\n", count);

This kind of bug may work correctly in debug mode (where stack memory is zeroed) and break in release mode (where stack memory is dirty)—you may not be able to catch it at all during development. The correct approach is simple: initialize at the point of declaration, int count = 0;.

Integer Overflow ​

Overflow of unsigned integers is well-defined (modular arithmetic), but overflow of signed integers is undefined behavior—the compiler is legally allowed to assume "signed integers never overflow" and optimize your overflow check away:

c
int a = 2000000000;
int b = 2000000000;
if (a + b < 0) {  // The compiler may delete this check outright!
    printf("Overflow detected!\n");
}

That's right: the compiler may delete this if check during the optimization stage, because it "knows" signed addition never overflows (per the C standard, an overflow would be UB, and the compiler may assume UB does not happen).

c
// Correct overflow check: test the operands before the addition
#include <limits.h>
if (a > INT_MAX - b) {
    printf("Overflow!\n");
}

Never use "the result is negative" to detect signed integer overflow—after an overflow, every assumption about the result is unreliable. The correct approach is to check the operands before the operation, for example a > INT_MAX - b.

Unterminated Strings ​

C strings end with \0 (the null byte). Forgetting this terminator is a classic beginner's mistake:

c
char greeting[5] = {'H', 'e', 'l', 'l', 'o'};
// No '\0' terminator!
printf("%s\n", greeting);  // Undefined behavior

printf's %s keeps reading until it encounters a \0. If the memory after greeting happens to be zero, you may get away with it; if not, printf will print a pile of garbage characters or even segfault.

c
// Correct version
char greeting[6] = {'H', 'e', 'l', 'l', 'o', '\0'};  // Terminated manually
char greeting[] = "Hello";  // A string literal adds '\0' automatically; the size is 6

There is another classic off-by-one: forgetting to leave room for the \0 when malloc-ing a string buffer:

c
char* result = malloc(strlen(s) + strlen(t));     // BUG! Missing the +1
char* result = malloc(strlen(s) + strlen(t) + 1); // OK: the +1 is for the '\0'

strlen returns the string length (excluding the \0), and strcpy and strcat copy the terminator, so the buffer needs strlen(s) + strlen(t) + 1 bytes.

Bridging to C++ ​

You will find that none of C++'s "new features" were invented out of thin air—they summarize decades of practical C experience and are engineered solutions aimed at real bug patterns. Only after understanding C's pitfalls can you truly understand why C++ is designed the way it is. The table below summarizes the key features C++ introduced to mitigate these traps:

Pitfall CategoryThe Problem in CC++ Mitigation
Greedy matching/* parsed as the start of a commentMore aggressive compiler warnings, templates instead of macros
Operator precedenceBitwise operators below comparisons, *p++ ambiguityconstexpr compile-time validation, type-safe bit operations with std::byte
= vs ==Assignment in a condition goes unreported-Wall warnings, [[nodiscard]], C++17 init-statements
Semicolon problemsEmpty bodies go unreported-Wempty-body warnings, [[fallthrough]] to mark intent explicitly
Declaration ambiguityFunction declaration vs. variable initializationBrace initialization T{}, auto type deduction, using instead of typedef
Array out-of-boundsNo bounds checkingstd::array::at(), std::vector::at(), std::span
Uninitialized variablesLocals hold garbage valuesConstructor initializer lists, in-class initializers
Integer overflowSigned overflow is UBstd::add_sat() (C++20), constexpr compile-time detection
Unterminated stringsManual \0 managementstd::string manages it automatically, safe views with std::string_view

Several key C++ improvements deserve special mention. Brace initialization (Timer t{}) eliminates the ambiguity of the "Most Vexing Parse", the auto keyword drastically reduces the need to write complex types by hand, and std::string fundamentally eliminates all the pitfalls of manual string management (memory allocation, terminators, buffer overflows). C++17's init-statement in if/switch (if (auto it = map.find(key); it != map.end())) both performs an assignment inside the condition and limits the variable's scope to the if/else. C++11's using alias is also more intuitive than typedef: using SignalHandler = void (*)(int) is understandable at a glance, while typedef void (*SignalHandler)(int) takes a moment to parse.

Exercises ​

Below are several exercises; the code deliberately contains traps—please find and fix them. All six are difficulty: basic, and each corresponds to a pitfall covered in this article (greedy matching, precedence, assignment vs. comparison, the extra semicolon, integer overflow, and a comprehensive one).

c
/// @brief Exercise 1: Fix the lexical analysis trap
/// The code below intends to compute the value of a / b, but the compiler disagrees
/// Hint: think about what greedy matching makes of the /*
/// @param a The dividend
/// @param b Pointer to the divisor
/// @return a / (*b)
int fix_lexical_trap(int a, int* b)
{
    // Exercise: fix the trap in the code
    return a/*b;
}
c
/// @brief Exercise 2: Fix the precedence trap
/// The code below intends to check whether the low 4 bits of flags are all zero
/// Hint: bitwise AND has lower precedence than ==
/// @param flags The flag bits to check
/// @return 1 means the low 4 bits are all zero, 0 means at least one bit is nonzero
int fix_priority_trap(unsigned int flags)
{
    // Exercise: fix the trap in the code
    return flags & 0x0F == 0;
}
c
/// @brief Exercise 3: Fix the assignment-vs-comparison trap
/// The code below intends to check whether x equals the target value
/// Hint: = and == in an if condition are different things
/// @param x The current value
/// @param target The target value
/// @return 1 means equal, 0 means not equal
int fix_assignment_trap(int x, int target)
{
    // Exercise: fix the trap in the code
    if (x = target)
        return 1;
    return 0;
}
c
/// @brief Exercise 4: Fix the semicolon trap
/// The function below intends to find the maximum value in the array
/// Hint: check whether there is an extra semicolon after the if
/// @param arr An array of integers
/// @param n The length of the array
/// @return The maximum value in the array
int fix_semicolon_trap(int* arr, int n)
{
    // Exercise: fix the trap in the code
    int max_val = arr[0];
    for (int i = 1; i < n; i++)
        if (arr[i] > max_val);
            max_val = arr[i];
    return max_val;
}
c
/// @brief Exercise 5: Fix the integer overflow check
/// The code below tries to detect whether a + b overflows
/// Hint: the result after overflow is undefined, so you cannot rely on the result to decide whether overflow happened
/// @param a The first addend (positive)
/// @param b The second addend (positive)
/// @return 1 means it will overflow, 0 means it is safe
int fix_overflow_check(int a, int b)
{
    // Exercise: fix the trap in the code
    if (a + b < 0)
        return 1;
    return 0;
}
c
/// @brief Exercise 6: Comprehensive challenge — fix the string concatenation function
/// The function below intends to concatenate two strings and return the new string
/// Hint: watch the allocation size, the string terminator, and null pointer checks
/// @param s The first string
/// @param t The second string
/// @return A newly allocated concatenated string; the caller is responsible for freeing it
char* fix_string_concat(const char* s, const char* t)
{
    // Exercise: fix all the traps in the code
    char* result = malloc(strlen(s) + strlen(t));
    strcpy(result, s);
    strcat(result, t);
    return result;
}

References ​

pdf-latest-4-g85128cc · 85128cc · 2026-10-05