Output & Running
Hello, World
C is compiled ahead of time and has no REPL. A program is a translation unit with an
int main(void) entry point, and nothing runs until the whole file compiles. #include pulls in the declarations for anything you call.print("Hello, World!") #include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
} The
\n is not optional — printf does not add a newline the way print does. The return 0 is the process exit status, which Python normally leaves implicit.printf format specifiers
C has no f-strings and no automatic conversion. Every value needs a specifier matching its exact type —
%d for int, %f for double, %s for a string, %zu for a size_t.city = "Reykjavik"
temperature = -3.5
readings = 12
print(f"{city}: {temperature}C over {readings} readings")
print(f"{temperature:.1f}") #include <stdio.h>
int main(void) {
const char *city = "Reykjavik";
double temperature = -3.5;
int readings = 12;
printf("%s: %.1fC over %d readings\n", city, temperature, readings);
printf("%.1f\n", temperature);
return 0;
} A mismatched specifier is undefined behavior, not an error — passing an
int where %f is expected prints garbage rather than raising. Modern compilers catch most of these with -Wall, which is why it should always be on.Static Types & Declarations
Every variable has a fixed type
A C variable's type is chosen at declaration and never changes. Python's annotations are optional hints checked by nothing at runtime; C's types are enforced by the compiler and determine how many bytes the variable occupies.
value = 42
print(type(value).__name__)
value = "now a string"
print(type(value).__name__)
count: int = 7
print(count) #include <stdio.h>
int main(void) {
int value = 42;
/* value = "now a string"; would not compile */
printf("%d\n", value);
const int count = 7;
printf("%d\n", count);
printf("an int occupies %zu bytes\n", sizeof(int));
return 0;
} const is the closest thing to a Python constant, and unlike the naming convention it is actually enforced. sizeof has no Python equivalent because Python objects do not have a size you can rely on — a Python int is a variable-width heap object.There are no objects, only values
Every Python value is a heap-allocated object with a type pointer and a reference count. A C
int is 4 bytes of stack memory with no header, no methods, and no identity — which is exactly why C is fast and why int has nothing you can call on it.value = 42
print(value.bit_length())
print(value.__class__)
print(id(value) is not None)
import sys
print(sys.getsizeof(value), "bytes for a Python int") #include <stdio.h>
int main(void) {
int value = 42;
/* value.bit_length() does not exist -- an int has no members */
printf("%d\n", value);
printf("%zu bytes for a C int\n", sizeof value);
int zero = 0;
if (!zero) {
printf("0 is falsy in C too\n");
}
return 0;
} C does have truthiness of a sort — zero is false and everything else is true — but it applies only to numbers and pointers, not to containers. There is no
__bool__ to define, because there are no types to define it on.char is a small integer
C has no character type distinct from numbers —
char is a one-byte integer, and a character literal like 'A' is the number 65. Python's ord and chr conversions are simply not needed.letter = "A"
print(ord(letter))
print(chr(ord(letter) + 1))
print(letter.islower())
print("abc"[1]) #include <stdio.h>
#include <ctype.h>
int main(void) {
char letter = 'A';
printf("%d\n", letter);
printf("%c\n", letter + 1);
printf("%d\n", islower((unsigned char) letter));
printf("%c\n", "abc"[1]);
return 0;
} Whether plain
char is signed is implementation-defined, which is why the <ctype.h> functions are called with an unsigned char cast — passing a negative char to islower is undefined behavior.Implicit promotion and explicit casts
C silently converts between numeric types in mixed expressions, following promotion rules, and truncates when assigning to a narrower type. Python raises on nothing here because it never converts implicitly at all.
print(7 / 2)
print(int(3.99))
print(float(7))
value = 300
print(value.to_bytes(2, "little")) #include <stdio.h>
#include <stdint.h>
int main(void) {
int whole = 7;
printf("%f\n", whole / 2.0);
printf("%d\n", (int) 3.99);
/* Assigning to a narrower type truncates silently. */
uint8_t narrow = (uint8_t) 300;
printf("%u\n", narrow);
/* Integer division happens BEFORE the cast to double. */
printf("%f\n", (double) (7 / 2));
return 0;
} The last line is the classic mistake: the division is already integer by the time the cast runs, so it prints
3.000000. Cast an operand, not the result.Integers & Arithmetic
Integers have a fixed width and overflow
Python integers grow without limit. A C
int is typically 32 bits, and exceeding its range is undefined behavior — not a wrap you can rely on, but a case the compiler is entitled to assume never happens.big = 2 ** 70
print(big)
print(2 ** 31 - 1 + 1)
import sys
print(sys.maxsize) #include <stdio.h>
#include <limits.h>
#include <stdint.h>
int main(void) {
printf("%d\n", INT_MAX);
/* Unsigned overflow is defined to wrap; signed overflow is not. */
unsigned int wrapped = UINT_MAX;
wrapped = wrapped + 1U;
printf("%u\n", wrapped);
int64_t wider = INT64_C(1) << 62;
printf("%lld\n", (long long) wider);
return 0;
} Use the fixed-width types from
<stdint.h> — int32_t, uint64_t — when the width matters, since plain int and long vary by platform. There is no arbitrary-precision integer in the language; that is a library, and CPython's is one of the more intricate parts of its source.Division truncates toward zero
Python's
/ always produces a float and // floors toward negative infinity. C's / on two integers truncates toward zero, so the two languages disagree on negative operands.print(7 / 2)
print(7 // 2)
print(-7 // 2)
print(-7 % 2) #include <stdio.h>
int main(void) {
printf("%f\n", 7.0 / 2.0);
printf("%d\n", 7 / 2);
printf("%d\n", -7 / 2);
printf("%d\n", -7 % 2);
return 0;
} The last two lines are the trap: Python gives
-4 and 1, C gives -3 and -1. Any algorithm ported between the languages that relies on modular arithmetic over negative values needs checking.Bit flags — how C APIs take options
Python passes options as keyword arguments or strings —
open(path, "r"). C APIs pack them into one integer, one bit per option, combined with |. You will not get far into any system header without meeting this, so it is worth reading fluently rather than deriving each time.from enum import Flag, auto
class Permission(Flag):
READ = auto()
WRITE = auto()
EXECUTE = auto()
permissions = Permission.READ | Permission.WRITE
print(permissions)
print(Permission.WRITE in permissions)
permissions &= ~Permission.WRITE
print(Permission.WRITE in permissions) #include <stdio.h>
#define PERMISSION_READ (1u << 0)
#define PERMISSION_WRITE (1u << 1)
#define PERMISSION_EXECUTE (1u << 2)
int main(void) {
unsigned int permissions = PERMISSION_READ | PERMISSION_WRITE;
printf("%u\n", permissions);
printf("%d\n", (permissions & PERMISSION_WRITE) != 0);
permissions &= ~PERMISSION_WRITE; /* clear one bit */
printf("%d\n", (permissions & PERMISSION_WRITE) != 0);
printf("%u\n", permissions);
return 0;
} Testing a flag is
& then a comparison, not == — permissions == PERMISSION_WRITE is only true when no other flag is set, which is a real and easy bug. Use unsigned for flag words: ~ on a signed value drags in sign extension you did not intend.Strings & Character Arrays
A string is a pointer to bytes
C has no string type. A "string" is a block of
char ending in a zero byte, and the value you pass around is a pointer to its first character. Length is not stored — strlen walks the bytes until it finds the terminator.greeting = "Hello"
print(len(greeting))
print(greeting[0])
print(greeting.upper())
print(greeting + ", World") #include <stdio.h>
#include <string.h>
#include <ctype.h>
int main(void) {
const char *greeting = "Hello";
printf("%zu\n", strlen(greeting));
printf("%c\n", greeting[0]);
char upper[16];
strcpy(upper, greeting);
for (size_t index = 0; upper[index] != '\0'; index++) {
upper[index] = (char) toupper((unsigned char) upper[index]);
}
printf("%s\n", upper);
char combined[32];
snprintf(combined, sizeof combined, "%s, World", greeting);
printf("%s\n", combined);
return 0;
} strlen is O(n) where Python's len is O(1), because a Python str stores its length in the object header. Note snprintf rather than strcat: it takes the destination size and cannot overflow the buffer.Comparing strings compares addresses
Using
== on two char * values compares the pointers, not the text. strcmp is the content comparison, returning a negative, zero, or positive number rather than a boolean.first = "hello"
built = "hel" + "lo"
print(first == built)
print(first is built)
print("apple" < "banana") #include <stdio.h>
#include <string.h>
int main(void) {
const char *first = "hello";
char built[8];
snprintf(built, sizeof built, "%s%s", "hel", "lo");
printf("%d\n", first == built);
printf("%d\n", strcmp(first, built) == 0);
printf("%d\n", strcmp("apple", "banana") < 0);
return 0;
} This is the same distinction Python draws between
== and is, except C only gives you is and makes you call a function for ==. Forgetting strcmp produces a comparison that is usually false and occasionally true, which is worse than always being wrong.String literals are not writable
A string literal lives in read-only memory. Assigning through a
char * that points at one is undefined behavior — usually a crash. To mutate text you need an array you own, which is why the buffer is declared as char name[] here.name = "ada"
# Python strings are immutable; you build a new one.
capitalized = name[0].upper() + name[1:]
print(capitalized)
print(name) #include <stdio.h>
#include <ctype.h>
int main(void) {
/* An array copy, not a pointer to the literal -- this one is writable. */
char name[] = "ada";
name[0] = (char) toupper((unsigned char) name[0]);
printf("%s\n", name);
const char *literal = "ada";
/* literal[0] = 'A'; undefined behavior: read-only memory */
printf("%s\n", literal);
return 0;
} Python strings are immutable by design and every "modification" allocates a new one. C lets you mutate a buffer in place, which is faster and is why
const on a char * matters so much — it is the only thing marking which pointers you may write through.Splitting a string
There is no
split returning a list. strtok walks a string and returns each token, but it modifies the buffer in place, writing terminators over the separators — so it cannot be used on a literal.line = "Reykjavik,-3.5,12"
fields = line.split(",")
print(fields)
print(len(fields))
print(fields[1]) #include <stdio.h>
#include <string.h>
int main(void) {
char line[] = "Reykjavik,-3.5,12"; /* writable copy, not a literal */
int count = 0;
for (char *field = strtok(line, ","); field != NULL; field = strtok(NULL, ",")) {
printf("%s\n", field);
count++;
}
printf("%d fields\n", count);
return 0;
} Passing
NULL on later calls tells strtok to continue where it left off, which it tracks in static state — so it is not reentrant and cannot be nested or used from threads. strtok_r is the reentrant version.Arrays
Arrays are fixed and unchecked
A C array is a contiguous block of one type, sized at declaration. It cannot grow, it does not know its own length once passed to a function, and indexing out of range is not checked — there is no
IndexError.numbers = [10, 20, 30]
numbers.append(40)
print(len(numbers), numbers[0])
try:
print(numbers[99])
except IndexError as error:
print("IndexError:", error) #include <stdio.h>
int main(void) {
int numbers[4] = { 10, 20, 30 };
size_t length = sizeof numbers / sizeof numbers[0];
printf("%zu %d\n", length, numbers[0]);
numbers[3] = 40;
for (size_t index = 0; index < length; index++) {
printf("%d ", numbers[index]);
}
printf("\n");
return 0;
} The
sizeof numbers / sizeof numbers[0] idiom computes the length, but it only works where the array is declared — pass it to a function and it decays to a pointer, losing the size. That is why C functions taking arrays almost always take a length parameter too.Building a growable list by hand
There is no
list. A growable array is a pointer, a count, and a capacity that you resize with realloc when it fills — which is exactly what CPython's list does internally, and what list.append amortizes for you.numbers = []
for value in range(5):
numbers.append(value * value)
print(numbers)
print(len(numbers)) #include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t capacity = 2;
size_t count = 0;
int *numbers = malloc(capacity * sizeof *numbers);
if (numbers == NULL) return 1;
for (int value = 0; value < 5; value++) {
if (count == capacity) {
capacity *= 2;
int *grown = realloc(numbers, capacity * sizeof *numbers);
if (grown == NULL) { free(numbers); return 1; }
numbers = grown;
}
numbers[count++] = value * value;
}
for (size_t index = 0; index < count; index++) {
printf("%d ", numbers[index]);
}
printf("\ncount %zu capacity %zu\n", count, capacity);
free(numbers);
return 0;
} Note the
realloc result goes into a separate variable first — assigning it straight back to numbers would leak the original block if the call failed. Doubling the capacity is what keeps append amortized O(1), in C and in CPython alike.Two-dimensional arrays are contiguous
A C 2D array is one flat block of memory, not a list of lists.
grid[1][2] is computed arithmetically from the row width, so there are no inner objects and no ragged rows.grid = [[1, 2, 3], [4, 5, 6]]
print(grid[1][2])
print(len(grid), len(grid[0]))
grid[0].append(99) # rows can differ in length
print(grid) #include <stdio.h>
int main(void) {
int grid[2][3] = { { 1, 2, 3 }, { 4, 5, 6 } };
printf("%d\n", grid[1][2]);
printf("%zu %zu\n", sizeof grid / sizeof grid[0],
sizeof grid[0] / sizeof grid[0][0]);
/* One contiguous block: the rows are adjacent in memory. */
int *flat = &grid[0][0];
printf("%d\n", flat[5]);
return 0;
} Because the rows are adjacent,
flat[5] reaches grid[1][2] — the indexing is just row * width + column. NumPy arrays are laid out exactly this way, which is why they are fast and Python lists of lists are not.Pointers
Pointers — the thing Python hides
Every Python name is already a reference to a heap object; you simply cannot see or manipulate the reference itself. A C pointer is that reference made explicit:
& takes the address of something and * reads through it.value = 42
alias = value
print(value, alias)
print(id(value) == id(alias))
numbers = [1, 2, 3]
same_list = numbers
same_list.append(4)
print(numbers) #include <stdio.h>
int main(void) {
int value = 42;
int *pointer = &value;
printf("%d\n", *pointer);
*pointer = 99;
printf("%d\n", value);
printf("pointer is %zu bytes\n", sizeof pointer);
return 0;
} Writing through
*pointer changes value itself — Python has no way to express this, because rebinding a name never affects another name. The mutable-list aliasing in the Python column is the closest analogy: two names, one object.Modifying a caller's variable
C passes everything by value, so a function receives a copy and cannot change the caller's variable — unless it receives a pointer to it. This is how C returns more than one value, since there are no tuples.
def divide(numerator, denominator):
return numerator // denominator, numerator % denominator
quotient, remainder = divide(17, 5)
print(quotient, remainder) #include <stdio.h>
void divide(int numerator, int denominator, int *quotient, int *remainder) {
*quotient = numerator / denominator;
*remainder = numerator % denominator;
}
int main(void) {
int quotient = 0;
int remainder = 0;
divide(17, 5, "ient, &remainder);
printf("%d %d\n", quotient, remainder);
return 0;
} This out-parameter pattern is everywhere in C APIs, and it is why so many functions return only a status code. Python's tuple return makes the same job invisible, which is one of the clearest ergonomic wins it has.
Pointer arithmetic and array decay
Adding to a pointer advances it by whole elements, not bytes —
pointer + 1 on an int * moves four bytes. Array indexing is defined in terms of this: numbers[2] literally means *(numbers + 2).numbers = [10, 20, 30, 40]
print(numbers[2])
print(numbers[1:3])
for value in numbers:
print(value, end=" ")
print() #include <stdio.h>
int main(void) {
int numbers[4] = { 10, 20, 30, 40 };
int *cursor = numbers;
printf("%d\n", numbers[2]);
printf("%d\n", *(numbers + 2));
for (int *scan = numbers; scan < numbers + 4; scan++) {
printf("%d ", *scan);
}
printf("\n");
printf("%td\n", (numbers + 3) - cursor);
return 0;
} There is no slicing — a "slice" in C is a pointer plus a length, passed as two arguments, with no copy and no bounds check. This is precisely why buffer overruns are a C problem and not a Python one.
NULL and void pointers
NULL is the pointer that points nowhere, the rough counterpart of None — but dereferencing it crashes rather than raising AttributeError. void * is a pointer to an unknown type, which is how C writes generic code.value = None
print(value is None)
try:
print(value.bit_length())
except AttributeError as error:
print("AttributeError:", error)
items = [1, "two", 3.0] # a list holds anything
print([type(item).__name__ for item in items]) #include <stdio.h>
void print_as(const void *data, char kind) {
if (data == NULL) {
printf("(null)\n");
return;
}
if (kind == 'i') printf("%d\n", *(const int *) data);
if (kind == 'd') printf("%.1f\n", *(const double *) data);
}
int main(void) {
int whole = 42;
double fraction = 3.5;
print_as(&whole, 'i');
print_as(&fraction, 'd');
print_as(NULL, 'i');
return 0;
} A
void * carries no type information at all, so the caller must tell the function what it is pointing at — the kind parameter here. This is why C generic containers are error-prone, and why CPython passes PyObject * everywhere instead: that struct carries its own type pointer.Manual Memory Management
malloc and free
Python allocates and frees for you by counting references. In C you call
malloc for heap memory and free exactly once when done. Forgetting leaks; freeing twice corrupts the heap; using after free is undefined.import sys
numbers = [0] * 5
print(sys.getrefcount(numbers) - 1, "reference(s)")
alias = numbers
print(sys.getrefcount(numbers) - 1, "reference(s)")
del alias
print(sys.getrefcount(numbers) - 1, "reference(s)")
# The object is freed when the last reference goes away. #include <stdio.h>
#include <stdlib.h>
int main(void) {
int *numbers = calloc(5, sizeof *numbers);
if (numbers == NULL) {
return 1;
}
numbers[0] = 42;
printf("%d %d\n", numbers[0], numbers[4]);
free(numbers);
numbers = NULL; /* guards against accidental reuse */
printf("%d\n", numbers == NULL);
return 0;
} calloc zeroes the memory; malloc does not, so reading uninitialized malloc'd memory gives you whatever was there before. Setting the pointer to NULL after freeing turns a silent use-after-free into an immediate, obvious crash.Stack and heap, and the dangling pointer
A local variable lives on the stack and disappears when the function returns. Returning a pointer to one is a classic bug: the address is still valid-looking but the memory has been reclaimed. Anything outliving its function must be on the heap.
def make_greeting(name):
# The string outlives the call because Python heap-allocates it
# and keeps it alive by reference count.
return f"Hello, {name}"
print(make_greeting("Ada")) #include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *make_greeting(const char *name) {
size_t size = strlen(name) + 8;
char *greeting = malloc(size);
if (greeting == NULL) return NULL;
snprintf(greeting, size, "Hello, %s", name);
return greeting; /* heap: safe to return */
}
int main(void) {
char *greeting = make_greeting("Ada");
if (greeting == NULL) return 1;
printf("%s\n", greeting);
free(greeting); /* the CALLER frees it */
return 0;
} Note what the signature does not say: nothing in
char *make_greeting(...) tells you the caller must free the result. Ownership is a documentation convention in C, and getting it wrong in either direction is a leak or a double free.Structs
Structs instead of classes
A
struct groups fields into one value. It has no methods, no inheritance, and no self — the C convention is free functions that take a pointer to the struct as their first parameter, which is what self is underneath.from dataclasses import dataclass
@dataclass
class Employee:
name: str
salary: float
def describe(self) -> str:
return f"{self.name} earns {self.salary:.0f}"
employee = Employee("Ada", 90000)
print(employee.describe())
print(employee) #include <stdio.h>
struct Employee {
const char *name;
double salary;
};
void describe(const struct Employee *employee) {
printf("%s earns %.0f\n", employee->name, employee->salary);
}
int main(void) {
struct Employee employee = { .name = "Ada", .salary = 90000 };
describe(&employee);
printf("%zu bytes\n", sizeof employee);
return 0;
} employee->name is shorthand for (*employee).name. The designated initializer { .name = ... } is the closest C gets to keyword arguments, and it zeroes any field you omit.Structs are copied by value
Assigning a struct copies every field. This is the opposite of Python, where assigning an object copies only the reference — and it means a C function taking a struct by value cannot modify the caller's copy.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
first = Point(1, 2)
second = first # same object
second.x = 99
print(first)
import copy
third = copy.copy(first)
third.x = 7
print(first, third) #include <stdio.h>
struct Point {
int x;
int y;
};
int main(void) {
struct Point first = { 1, 2 };
struct Point second = first; /* a full copy */
second.x = 99;
printf("%d %d\n", first.x, second.x);
struct Point *alias = &first; /* a reference */
alias->x = 7;
printf("%d\n", first.x);
return 0;
} Getting Python's aliasing behavior requires explicitly taking a pointer; getting C's copying behavior in Python requires
copy.copy. Each language makes the other's default the thing you have to ask for.typedef and enum
typedef gives a type a shorter name, so struct Employee can be written as Employee. enum declares named integer constants — closer to Python's IntEnum than to Enum, since the values really are integers.from enum import IntEnum
class Status(IntEnum):
ACTIVE = 0
SUSPENDED = 1
print(Status.ACTIVE, int(Status.ACTIVE))
print(Status(1).name)
print(list(Status)) #include <stdio.h>
typedef enum {
STATUS_ACTIVE,
STATUS_SUSPENDED
} Status;
typedef struct {
const char *name;
Status status;
} Employee;
const char *status_name(Status status) {
switch (status) {
case STATUS_ACTIVE: return "ACTIVE";
case STATUS_SUSPENDED: return "SUSPENDED";
}
return "UNKNOWN";
}
int main(void) {
Employee employee = { .name = "Ada", .status = STATUS_SUSPENDED };
printf("%s %d\n", status_name(employee.status), employee.status);
return 0;
} An
enum is not a namespace — the constant names go straight into global scope, which is why they are written STATUS_ACTIVE rather than ACTIVE. There is also no way to iterate one or to recover a name, so status_name has to be written by hand.Padding — sizeof is not the sum of the fields
The compiler inserts unnamed padding bytes so each field starts at an address its type requires. A struct is therefore usually larger than its fields add up to, and reordering the fields changes the size — something that matters the moment you describe a struct to
ctypes or read a binary format.import struct
# '@' uses native alignment and padding; '=' packs with none.
print(struct.calcsize("@cdc"))
print(struct.calcsize("@dcc"))
print(struct.calcsize("=cdc")) #include <stdio.h>
#include <stddef.h>
struct Wasteful {
char flag;
double value;
char code;
};
struct Tidy {
double value;
char flag;
char code;
};
int main(void) {
printf("%zu\n", sizeof(struct Wasteful));
printf("%zu\n", sizeof(struct Tidy));
printf("%zu\n", offsetof(struct Wasteful, value));
printf("%zu\n", 1 + sizeof(double) + 1);
return 0;
} Both structs hold identical data, yet the wasteful one is larger purely because a
char sits before the double. Ordering fields widest-first is the usual fix. Note the last line: the naive sum is smaller than either real size, which is exactly the assumption that corrupts a hand-written ctypes.Structure.Functions
Functions declare types and nothing else
A C function fixes its parameter types, its return type, and its arity. There are no default arguments, no keyword arguments, no
*args, and no returning a different type depending on input.def price(amount, currency="USD", *, precision=2):
return f"{amount:.{precision}f} {currency}"
print(price(19.5))
print(price(19.5, "ISK"))
print(price(19.5, precision=0)) #include <stdio.h>
void print_price(double amount, const char *currency, int precision) {
printf("%.*f %s\n", precision, amount, currency);
}
int main(void) {
print_price(19.5, "USD", 2);
print_price(19.5, "ISK", 2);
print_price(19.5, "USD", 0);
return 0;
} Every call must pass every argument, so the defaults Python expresses in the signature become wrapper functions or documented constants. The
%.*f specifier takes the precision as a preceding argument — a rare piece of runtime flexibility in printf.Function pointers
Functions are not values in C, but their addresses are. A function pointer's type spells out the full signature, which is why the declaration syntax is famously awkward —
int (*compare)(const void *, const void *).def apply_twice(operation, start):
return operation(operation(start))
with_tax = lambda amount: amount * 1.24
print(round(apply_twice(with_tax, 100), 2))
names = ["Cleo", "ada", "Bob"]
print(sorted(names, key=str.lower)) #include <stdio.h>
#include <stdlib.h>
#include <string.h>
double with_tax(double amount) {
return amount * 1.24;
}
double apply_twice(double (*operation)(double), double start) {
return operation(operation(start));
}
int compare_strings(const void *left, const void *right) {
return strcmp(*(const char **) left, *(const char **) right);
}
int main(void) {
printf("%.2f\n", apply_twice(with_tax, 100.0));
const char *names[3] = { "Cleo", "Bob", "Ada" };
qsort(names, 3, sizeof names[0], compare_strings);
printf("%s %s %s\n", names[0], names[1], names[2]);
return 0;
} A function pointer captures nothing — there are no closures, so any extra state must be passed separately or held in a global. Note
qsort's comparator takes const void * and casts, because C has no generics.static, globals, and file scope
C has no modules. Every non-
static name is visible to the whole program at link time; marking a function or variable static confines it to its file, which is the only privacy mechanism the language has._call_count = 0 # convention only: still importable
def _helper():
return "module-private by convention"
def counted():
global _call_count
_call_count += 1
return _call_count
print(counted())
print(counted())
print(_helper()) #include <stdio.h>
/* Confined to this file: the linker will not expose it. */
static const char *helper(void) {
return "file-private, enforced";
}
static int counted(void) {
static int call_count = 0; /* persists across calls */
call_count++;
return call_count;
}
int main(void) {
printf("%d\n", counted());
printf("%d\n", counted());
printf("%s\n", helper());
return 0;
} The
static local inside counted keeps its value between calls without being visible outside the function — Python needs a global, a closure, or a function attribute for the same effect. Note the keyword means two different things depending on where it appears.Control Flow
Loops are counters, not iterators
C has no iterator protocol and no
for x in y. The for loop is an initializer, a condition, and an increment — you manage the index and the bound yourself.for index in range(3):
print(index)
for city in ["Oslo", "Bergen"]:
print(city)
for index, city in enumerate(["Oslo", "Bergen"]):
print(index, city) #include <stdio.h>
int main(void) {
for (int index = 0; index < 3; index++) {
printf("%d\n", index);
}
const char *cities[2] = { "Oslo", "Bergen" };
size_t count = sizeof cities / sizeof cities[0];
for (size_t index = 0; index < count; index++) {
printf("%zu %s\n", index, cities[index]);
}
return 0;
} Because the bound is written by hand at every loop, off-by-one errors are C's signature bug — and unlike Python's
IndexError, an overrun reads adjacent memory silently. Generators, comprehensions, and zip have no counterpart at all.switch and fall-through
C's
switch jumps to a matching case and then runs on until a break. Python's match never falls through and can destructure — switch only compares integers and characters.def category(status):
match status:
case 200 | 201 | 204:
return "success"
case 301 | 302:
return "redirect"
case _:
return "error"
for status in (200, 302, 500):
print(category(status)) #include <stdio.h>
const char *category(int status) {
switch (status) {
case 200:
case 201:
case 204:
return "success";
case 301:
case 302:
return "redirect";
default:
return "error";
}
}
int main(void) {
int statuses[3] = { 200, 302, 500 };
for (size_t index = 0; index < 3; index++) {
printf("%s\n", category(statuses[index]));
}
return 0;
} Stacked
case labels with no statements between them are the intentional use of fall-through; forgetting a break in the middle of a block is the accidental one. switch cannot match on a string, which is why C code dispatches on enums or first characters.while, do-while, and no for-else
C's
while matches Python's. It adds do { } while (...), which always runs the body at least once, and it lacks the else clause Python attaches to loops.countdown = 3
while countdown > 0:
print(countdown)
countdown -= 1
for value in range(5):
if value == 3:
break
else:
print("never reached")
print("done") #include <stdio.h>
int main(void) {
int countdown = 3;
while (countdown > 0) {
printf("%d\n", countdown);
countdown--;
}
int attempts = 0;
do {
attempts++;
} while (attempts < 1);
printf("ran %d time(s)\n", attempts);
for (int value = 0; value < 5; value++) {
if (value == 3) break;
}
printf("done\n");
return 0;
} Python's loop
else — which runs only when no break fired — has no C equivalent, so the usual translation is a flag variable set before the break. do/while is mostly used for macros and for input-validation loops.Error Handling
Return codes instead of exceptions
C has no exceptions and no stack unwinding. A function reports failure through its return value — a status code, a
NULL pointer, or -1 — and the caller must check it. Nothing forces the check.def parse_positive(text):
value = int(text) # raises ValueError on bad input
if value <= 0:
raise ValueError("must be positive")
return value
for text in ("5", "-1", "abc"):
try:
print(parse_positive(text))
except ValueError as error:
print("rejected:", error) #include <stdio.h>
#include <stdlib.h>
#include <errno.h>
/* Returns 0 on success and writes through out; non-zero on failure. */
int parse_positive(const char *text, long *out) {
char *end = NULL;
errno = 0;
long value = strtol(text, &end, 10);
if (end == text || *end != '\0') return 1; /* not a number */
if (errno == ERANGE) return 2; /* out of range */
if (value <= 0) return 3; /* not positive */
*out = value;
return 0;
}
int main(void) {
const char *inputs[3] = { "5", "-1", "abc" };
for (size_t index = 0; index < 3; index++) {
long parsed = 0;
int status = parse_positive(inputs[index], &parsed);
if (status == 0) {
printf("%ld\n", parsed);
} else {
printf("rejected: status %d\n", status);
}
}
return 0;
} Note that
atoi is unusable for this because it cannot distinguish "0" from a parse failure — strtol with an end pointer is the correct tool. An unchecked return value is the root of an enormous share of real C bugs, and no compiler warning covers all of them.errno and cleanup on failure
Library calls report the reason for failure in the global
errno, readable as text with strerror. With no finally and no context managers, cleanup on an error path is written by hand — often with a goto to a single exit block.import os
try:
with open("/definitely/not/here.txt") as handle:
print(handle.read())
except OSError as error:
print("failed:", error.strerror)
print("errno:", error.errno) #include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(void) {
FILE *handle = fopen("/definitely/not/here.txt", "r");
if (handle == NULL) {
printf("failed: %s\n", strerror(errno));
printf("errno: %d\n", errno);
return 0;
}
fclose(handle);
return 0;
} errno is only meaningful immediately after a failed call — any intervening library call may overwrite it, so read it first. The goto cleanup idiom that C uses for multi-resource error paths is the one place goto is genuinely idiomatic.assert and invariants
assert checks a condition and aborts the process with a message if it fails. Unlike Python's, it is removed entirely when NDEBUG is defined — so it must never contain code with side effects.def average(values):
assert len(values) > 0, "values must not be empty"
return sum(values) / len(values)
print(average([1, 2, 3]))
try:
average([])
except AssertionError as error:
print("AssertionError:", error) #include <stdio.h>
#include <assert.h>
double average(const double *values, size_t count) {
assert(count > 0 && "values must not be empty");
double total = 0;
for (size_t index = 0; index < count; index++) {
total += values[index];
}
return total / (double) count;
}
int main(void) {
double values[3] = { 1, 2, 3 };
printf("%.2f\n", average(values, 3));
return 0;
} The
&& "message" trick works because a string literal is always truthy, so it does not change the condition but does appear in the abort output. A failed assert calls abort() — there is nothing to catch, unlike Python's AssertionError.Undefined behavior — the mistakes that never report
Python has no equivalent of this and it is the hardest idea on the page. Some C mistakes are not errors and not crashes: the standard declares the program meaningless, and the compiler is then free to assume they never happen. Code can work at
-O0 and change behavior at -O2, because an optimizer deleted a check it was entitled to consider impossible.import sys
def would_overflow(value):
# Python integers grow, so this question has no meaning --
# there is no value at which addition misbehaves.
return False
print(would_overflow(sys.maxsize))
print(sys.maxsize + 1)
print((sys.maxsize + 1) > sys.maxsize) #include <stdio.h>
#include <limits.h>
#include <stdbool.h>
/* WRONG. Signed overflow is undefined, so the compiler may assume
value + 1 is always greater than value and delete this check.
Called with 5 it is well defined; the bug only bites at INT_MAX. */
bool would_overflow_broken(int value) {
return value + 1 < value;
}
/* RIGHT. Ask the question without ever performing the overflow. */
bool would_overflow(int value) {
return value > INT_MAX - 1;
}
int main(void) {
printf("%d\n", would_overflow(INT_MAX));
printf("%d\n", would_overflow(5));
printf("%d\n", would_overflow_broken(5));
return 0;
} The broken check is the classic case: it reads as obviously correct, tests fine in a debug build, and vanishes under optimization. The rule to carry over is that "it worked when I ran it" proves much less in C than in Python — always phrase a check so the dangerous operation never actually occurs, and see the sanitizer row below for the tool that catches the rest.
Files & I/O
Writing and reading a file
There is no
with statement, so every fopen needs a matching fclose on every path out of the function. tmpfile() is used here because it returns a handle to a file that is deleted automatically when closed.import tempfile
with tempfile.TemporaryFile("w+") as handle:
handle.write("Reykjavik,-3.5\n")
handle.write("Oslo,1.0\n")
handle.seek(0)
for line in handle:
print(line.rstrip()) #include <stdio.h>
int main(void) {
FILE *handle = tmpfile();
if (handle == NULL) {
return 1;
}
fprintf(handle, "Reykjavik,-3.5\n");
fprintf(handle, "Oslo,1.0\n");
rewind(handle);
char line[64];
while (fgets(line, sizeof line, handle) != NULL) {
printf("%s", line);
}
fclose(handle);
return 0;
} fgets takes the buffer size and stops before overrunning it, unlike the removed gets. It also keeps the trailing newline in the buffer, which is why the printf here has no \n of its own — a detail Python hides with rstrip.Command-line arguments
Arguments arrive as parameters to
main: a count and an array of strings. argv[0] is the program name, exactly like sys.argv[0], and argv[argc] is guaranteed to be NULL.import sys
arguments = sys.argv
print("program:", "example")
print("count:", len(arguments))
for index, argument in enumerate(arguments[1:], start=1):
print(index, argument) #include <stdio.h>
int main(int argc, char *argv[]) {
printf("program: %s\n", argv[0] != NULL ? "example" : "?");
printf("count: %d\n", argc);
for (int index = 1; index < argc; index++) {
printf("%d %s\n", index, argv[index]);
}
return 0;
} Note the signature changes from
int main(void) — those are the only two forms the standard guarantees. There is no argparse in the standard library, so option parsing is either hand-written or getopt.Headers & the Preprocessor
Headers are not imports
#include is textual substitution performed before compilation — it pastes the file in. It does not load a module, create a namespace, or run anything, so C has no equivalent of import x as y and no way to scope a name to a module.import math
from collections import Counter
print(math.sqrt(16))
print(Counter("hello")["l"])
print(math.pi) #include <stdio.h>
#include <math.h>
int main(void) {
printf("%.1f\n", sqrt(16.0));
printf("%.5f\n", M_PI);
return 0;
} Because every name lands in one global namespace, C libraries prefix everything —
pthread_create, SDL_Init, PyObject_CallObject. A header declares functions; the actual code lives in a separately compiled library that the linker must also be told about.Macros are text, not functions
A
#define macro is expanded literally before the compiler sees it. It has no types, no scope, and evaluates its arguments as many times as they appear — which is why the parentheses in the definition below are mandatory.MAX_RETRIES = 3
def square(value):
return value * value
print(MAX_RETRIES)
print(square(3 + 1))
counter = 0
def next_value():
global counter
counter += 1
return counter
print(square(next_value())) #include <stdio.h>
#define MAX_RETRIES 3
#define SQUARE(value) ((value) * (value))
int main(void) {
printf("%d\n", MAX_RETRIES);
printf("%d\n", SQUARE(3 + 1));
int counter = 0;
/* The argument is evaluated TWICE -- this is the classic macro bug. */
printf("%d\n", SQUARE(++counter));
printf("counter is now %d\n", counter);
return 0;
} Without the inner parentheses,
SQUARE(3 + 1) expands to 3 + 1 * 3 + 1 and yields 7. The double evaluation shown on the last lines cannot be fixed with parentheses at all — it is why modern C prefers static inline functions to macros wherever a function will do.Building & Debugging
Splitting a program across files
Python resolves
import at run time by finding a file. C has no such step: a .h header declares what exists so each .c file can be compiled alone, and a separate link step joins the compiled pieces. The include guard stops the header being pasted in twice.# geometry.py
PI = 3.14159265358979323846
def circle_area(radius):
return PI * radius * radius
# main.py
from geometry import circle_area
print(f"{circle_area(2.0):.2f}")
# Run it: python3 main.py /* geometry.h -- the declaration */
#ifndef GEOMETRY_H
#define GEOMETRY_H
double circle_area(double radius);
#endif
/* geometry.c -- the definition */
#include "geometry.h"
#define PI 3.14159265358979323846
double circle_area(double radius) {
return PI * radius * radius;
}
/* main.c -- the user */
#include <stdio.h>
#include "geometry.h"
int main(void) {
printf("%.2f\n", circle_area(2.0));
return 0;
}
/* Build it:
cc -Wall -c geometry.c -> geometry.o
cc -Wall -c main.c -> main.o
cc geometry.o main.o -o example */ Each
.c file is compiled in isolation and knows nothing of the others — only what its headers declared. That is why a header can lie: if geometry.h promises a function that geometry.c never defines, compilation succeeds and the linker is what fails. Both columns are display-only here because each spans several files.Compiler errors versus linker errors
C splits name resolution into two stages, and they fail with completely different messages. The compiler asks whether a name was declared — usually by a header. The linker asks whether the code actually exists somewhere, in your object files or a library named with
-l.# One stage: Python resolves names when the line runs.
try:
import nonexistent_module
except ModuleNotFoundError as error:
print("no such module:", error.name)
import math
print(math.sqrt(16.0))
try:
math.nonexistent_function(1)
except AttributeError as error:
print("declared nowhere:", error) #include <stdio.h>
#include <math.h>
int main(void) {
/* Declared by <math.h>, so the COMPILER is satisfied.
Its machine code lives in libm, so on Linux the LINKER
needs -lm or it reports:
undefined reference to `sqrt'
On macOS libm is part of libSystem, so no flag is needed. */
printf("%.1f\n", sqrt(16.0));
/* Omitting #include <math.h> instead gives a COMPILER
diagnostic: implicit declaration of function 'sqrt'. */
return 0;
}
/* Build it:
cc -Wall main.c -o example -lm (Linux)
cc -Wall main.c -o example (macOS) */ Reading which stage failed saves a lot of time: "implicit declaration" means a missing
#include, while "undefined reference" means a missing -l flag or an object file left off the command. Python collapses both into one runtime error, so this is a distinction you have never had to make.Finding memory bugs you cannot see
The program below is wrong — it never frees what it allocates — and it runs perfectly, prints the right answer, and exits zero. Nothing in C reports a leak, so you need a tool that watches the allocator. Which tool depends on your platform, and the difference is worth knowing before you conclude your code is clean.
import tracemalloc
tracemalloc.start()
numbers = [value * value for value in range(1000)]
print(numbers[3])
current, peak = tracemalloc.get_traced_memory()
print(f"peak {peak > 0}")
tracemalloc.stop()
# Nothing leaks: the list is freed when the last reference goes. #include <stdio.h>
#include <stdlib.h>
int main(void) {
int *numbers = malloc(1000 * sizeof *numbers);
if (numbers == NULL) {
return 1;
}
for (int index = 0; index < 1000; index++) {
numbers[index] = index * index;
}
printf("%d\n", numbers[3]);
/* The bug: no free(numbers). Runs clean, exits 0, leaks 4000 bytes. */
return 0;
}
/* Catch it -- LEAK detection differs by platform:
Linux: cc -g -fsanitize=address example.c -o example && ./example
-> ERROR: LeakSanitizer: detected memory leaks
#0 malloc #1 main example.c:5
or: valgrind --leak-check=full ./example
macOS: LeakSanitizer is NOT supported here -- ASan prints
"detect_leaks is not supported on this platform".
Use the system tool instead:
cc -g example.c -o example
MallocStackLogging=1 leaks --atExit -- ./example
-> Process 44411: 1 leak for 4096 total leaked bytes */ Add
-fsanitize=address,undefined to every debug build and leave it there — on every platform it catches out-of-bounds access, use-after-free, and much of the undefined behavior described above, naming the exact line. Only leak detection is the macOS gap, and leaks covers it. These tools have no Python counterpart because Python has no such failure modes, and working without them is what makes C feel unmanageable.Debugging without a REPL
There is no
breakpoint() and no interactive interpreter to poke at a live object. C debugging is either printf or a real debugger — lldb on macOS, gdb on Linux — driving a binary you compiled with -g so the symbols survive.def running_total(values):
total = 0
for value in values:
# breakpoint() <- drops into pdb right here
total += value
return total
print(running_total([1, 2, 3, 4]))
# python3 -m pdb script.py for a whole-script session #include <stdio.h>
int running_total(const int *values, size_t count) {
int total = 0;
for (size_t index = 0; index < count; index++) {
total += values[index];
}
return total;
}
int main(void) {
int values[4] = { 1, 2, 3, 4 };
printf("%d\n", running_total(values, 4));
return 0;
}
/* Debug it:
cc -g -O0 example.c -o example
lldb ./example
(lldb) breakpoint set --name running_total
(lldb) run
(lldb) frame variable -- all locals
(lldb) print values[1] -- evaluate an expression
(lldb) step / next / continue */ Compile with
-g -O0 when debugging: optimizations reorder and delete code, so an optimized build steps through lines out of order and reports variables as "optimized out". The commands map closely onto pdb — step, next, continue, print all mean what you expect.Extending Python with C
What a Python object looks like in C
This is where the two columns stop being separate languages. Every Python object is a C struct beginning with a reference count and a type pointer —
PyObject. Everything sys.getsizeof reports is the size of that struct.import sys
value = 42
print(sys.getsizeof(value), "bytes")
print(sys.getrefcount(value), "references")
text = "hello"
print(sys.getsizeof(text), "bytes")
print(type(text).__name__) #include <stdio.h>
#include <stdint.h>
/* A simplified sketch of CPython's object header. */
struct TypeObject {
const char *name;
};
typedef struct {
intptr_t refcount; /* CPython spells this Py_ssize_t */
struct TypeObject *type;
} ObjectHeader;
typedef struct {
ObjectHeader header;
long value;
} IntObject;
int main(void) {
struct TypeObject int_type = { .name = "int" };
IntObject number = { .header = { .refcount = 1, .type = &int_type }, .value = 42 };
printf("%ld\n", number.value);
printf("%s\n", number.header.type->name);
printf("refcount %ld\n", (long) number.header.refcount);
printf("%zu bytes\n", sizeof number);
return 0;
} The refcount field is the whole of Python's memory management:
Py_INCREF and Py_DECREF adjust it, and the object is freed when it reaches zero. Every extension author's worst bugs come from getting those two calls unbalanced — a leak in one direction, a crash in the other.Calling C from Python with ctypes
ctypes loads a shared library and calls its functions directly, with no C code written on your side. You declare each function's argument and return types so the marshaling is correct — getting them wrong crashes the interpreter rather than raising.import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
print(libc.strlen(b"Reykjavik"))
libc.abs.argtypes = [ctypes.c_int]
libc.abs.restype = ctypes.c_int
print(libc.abs(-17)) #include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* The C side of the ctypes call above: ordinary library functions,
with no knowledge that Python exists. */
int main(void) {
printf("%zu\n", strlen("Reykjavik"));
printf("%d\n", abs(-17));
return 0;
} Setting
argtypes and restype is not optional bookkeeping — without them ctypes assumes int, which silently truncates a returned pointer on a 64-bit platform. This is the cheapest way to reach C from Python, and the reason it works is that the C function neither knows nor cares that Python called it.The GIL, and the threads underneath it
CPython threads are real OS threads, but the Global Interpreter Lock lets only one execute bytecode at a time — which is why threading does not speed up CPU-bound Python. A C extension may release the GIL and then run genuinely in parallel.
import threading
total = 0
lock = threading.Lock()
def add_many():
global total
for _ in range(50000):
with lock:
total += 1
workers = [threading.Thread(target=add_many) for _ in range(2)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print(total) #include <stdio.h>
/* What a C extension writes around a long computation so other Python
threads keep running. Expanded, the macros save the thread state and
drop the GIL, then reacquire it.
Py_BEGIN_ALLOW_THREADS
heavy_computation();
Py_END_ALLOW_THREADS
*/
long heavy_computation(long iterations) {
long total = 0;
for (long index = 0; index < iterations; index++) {
total += index % 7;
}
return total;
}
int main(void) {
printf("%ld\n", heavy_computation(1000000));
return 0;
} Between those two macros the C code must not touch any
PyObject — it no longer holds the lock protecting them. This is exactly how NumPy, and every other numeric extension, gets real multi-core throughput out of a language whose interpreter is single-threaded.