Hello, World & Output
Hello, World
There is no
main, no #include, no braces and no semicolons. A Python script is a sequence of statements executed top to bottom, and indentation is the block structure rather than a convention on top of it.#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}print("Hello, World!")One line against five, and the missing pieces are each doing something in C:
#include declares printf so the compiler knows its signature, main names the entry point for the linker, and return 0 sets the exit status. Python has an entry point too — the file — and an exit status, which is 0 unless you raise or call sys.exit. print also appends the newline that printf makes you write.Format Strings, Both Ways
Python's f-strings interpolate expressions directly and ask each value how to render itself. Python also kept C's conversion specifiers under the
% operator, which is the closest thing to a direct translation when you need field widths.#include <stdio.h>
int main(void) {
int count = 42;
double ratio = 1.5;
const char *name = "python";
printf("%d %.1f %s\n", count, ratio, name);
printf("%5d|\n", count);
return 0;
}count = 42
ratio = 1.5
name = "python"
print(f"{count} {ratio} {name}")
# The same conversion specifiers C uses, kept for field widths:
print("%5d|" % count)The f-string version cannot get the type wrong, because the value carries its type and is asked to render itself — where
printf believes the format string and reads whatever bytes the specifier implies. That is the same weakness C's variadic arguments have everywhere: %d against a long reads the wrong width, and only the compiler's special-casing of printf warns you.Names, Not Storage
Assignment Rebinds a Name
This is the row the rest of the page follows from. A C variable is storage with a type, and assignment writes into it. A Python name is a label attached to an object, and assignment moves the label — the object it pointed at is untouched.
#include <stdio.h>
int main(void) {
int value = 1;
int copy = value; /* copies the bits into separate storage */
copy = 99;
printf("%d %d\n", value, copy);
/* The type belongs to the STORAGE and cannot change. */
/* value = "text"; <- would not compile */
printf("%zu\n", sizeof(value));
return 0;
}value = 1
copy = value # both names now label the SAME object
copy = 99 # rebinds 'copy'; 'value' still labels 1
print(value, copy)
# The type belongs to the OBJECT, not the name, so a name may
# be rebound to something of a different type entirely.
value = "text"
print(type(value).__name__)The output happens to match for the integer, and that is worth noticing: because integers are immutable, rebinding is indistinguishable from copying. The difference only becomes visible with a mutable object — the next rows on lists show it. The second half is the part with no C counterpart at all:
sizeof(value) is a compile-time property of the storage, while type(value) is a runtime property of whatever the name currently points at.No Declarations, and No Block Scope
C scopes a variable to the innermost braces it was declared in. Python has no declarations and no block scope: a name assigned anywhere in a function is local to the whole function, including before the line that assigns it.
#include <stdio.h>
int main(void) {
int outer = 1;
if (outer == 1) {
int inner = 2; /* scoped to THIS block */
printf("%d\n", inner);
}
/* printf("%d\n", inner); <- would not compile: out of scope */
for (int index = 0; index < 2; index++) {
printf("%d\n", index);
}
/* index is gone here too. */
printf("%d\n", outer);
return 0;
}outer = 1
if outer == 1:
inner = 2 # NOT scoped to the if — it outlives the block
print(inner)
print(inner) # still bound: 2
for index in range(2):
print(index)
print(index) # the loop variable survives too: 1
print(outer)Both extra lines print in Python and neither compiles in C. The loop variable outliving the loop is the one that surprises people most, and it is occasionally useful — after a
for/else search, index tells you where you stopped. The cost is that a typo in a name is not a compile error but a NameError at run time, or worse, a silent rebinding of something that already existed.Integers That Do Not Overflow
Integers Grow Instead of Wrapping
A C
int is a fixed number of bits, and signed overflow is undefined behavior — the compiler may assume it cannot happen. A Python integer is an object that allocates more digits as needed, so there is no maximum and no wrap.#include <stdio.h>
#include <limits.h>
int main(void) {
printf("%d\n", INT_MAX);
/* Signed overflow is UNDEFINED, so compute in unsigned where
wrapping is defined, then reinterpret. */
unsigned int wrapped = (unsigned int) INT_MAX + 1u;
printf("%d\n", (int) wrapped);
/* 2**70 does not fit in any C integer type. */
printf("%.0f\n", 1.0e21);
return 0;
}import sys
print(sys.maxsize) # the largest MACHINE word, not a limit on int
# No overflow: the object grows.
print(sys.maxsize + 1)
# 2**70 is exact — no float involved, no ceiling.
print(2 ** 70)The third line is the real difference:
2 ** 70 is exact in Python and has no C counterpart short of a bignum library. sys.maxsize is not a limit on int at all — it is the largest value a machine word holds, which matters for indexing, not arithmetic. The price is that every integer operation is a method call on a heap object, which is a large part of why numeric Python loops are slow and why NumPy exists to put fixed-width arrays back underneath them.Division Truncates, Or Floors
Two genuine behavioral differences hide here. C's
/ on two integers truncates toward zero and gives an integer; Python's / always produces a float and // is the integer form. And the two languages round negative division in opposite directions.#include <stdio.h>
int main(void) {
printf("%d\n", 17 / 5); /* 3 — integer division */
printf("%d\n", 17 % 5); /* 2 */
/* C truncates TOWARD ZERO, so the remainder takes the
sign of the dividend. */
printf("%d\n", -17 / 5); /* -3 */
printf("%d\n", -17 % 5); /* -2 */
return 0;
}print(17 // 5) # 3 — // is the integer division
print(17 % 5) # 2
# Python FLOORS (toward negative infinity), so the remainder
# takes the sign of the DIVISOR.
print(-17 // 5) # -4, where C gives -3
print(-17 % 5) # 3, where C gives -2The last two lines disagree between the columns, and that is the lesson rather than a mistake — this is a real portability trap when translating an algorithm that uses
% on negatives, such as wrapping an index around a ring buffer. Python's convention has the property that a % n is always in [0, n) for positive n, which is usually what index arithmetic wants; C's matches the hardware division instruction.A List Is Not an Array
A List Holds Pointers, Not Values
A C array is contiguous storage of one type, sized once. A Python list is a growable vector of pointers to objects, which is why it can hold mixed types and why it costs a pointer dereference per element.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* One type, contiguous, sized up front. */
size_t capacity = 4;
int *numbers = malloc(capacity * sizeof(int));
if (numbers == NULL) return 1;
size_t count = 0;
for (int value = 1; value <= 3; value++) {
numbers[count++] = value * 10; /* growing means realloc, by hand */
}
for (size_t index = 0; index < count; index++) {
printf("%d ", numbers[index]);
}
printf("\n%zu\n", count);
free(numbers);
return 0;
}numbers = []
for value in range(1, 4):
numbers.append(value * 10) # grows itself
print(*numbers)
print(len(numbers))
# Because the list holds POINTERS, the elements need not share a type.
mixed = [1, "two", 3.0]
print([type(item).__name__ for item in mixed])The mixed list is the giveaway: a contiguous array of one type cannot do that, and a vector of pointers can. The consequence for a C programmer is a performance model, not just a convenience — a million-element list is a million pointers plus a million heap objects scattered across memory, where a C array is one cache-friendly block. That is exactly the gap
array.array and NumPy close when it matters.Two Names, One List
Assigning a list to another name does not copy it — both names label the same object, exactly as two C pointers can address the same buffer. The difference is that C makes the aliasing visible with
* and &, and Python does not.#include <stdio.h>
int main(void) {
int numbers[3] = { 10, 20, 30 };
/* Aliasing is EXPLICIT: you took an address. */
int *alias = numbers;
alias[0] = 99;
printf("%d\n", numbers[0]);
/* Copying is explicit too, and needs a size. */
int copy[3];
for (size_t index = 0; index < 3; index++) copy[index] = numbers[index];
copy[1] = 77;
printf("%d %d\n", numbers[1], copy[1]);
return 0;
}numbers = [10, 20, 30]
# Aliasing is INVISIBLE: plain assignment, same object.
alias = numbers
alias[0] = 99
print(numbers[0])
# Copying must be asked for.
copy = numbers[:] # or list(numbers)
copy[1] = 77
print(numbers[1], copy[1])This is the most common way Python surprises a C programmer, because the aliasing has no syntax. It is the same hazard as two pointers into one buffer, minus the notation that warns you. The related trap is the mutable default argument:
def add(item, into=[]) creates the list once, at definition time, and every call without an explicit second argument shares it — which is why the idiom is into=None and a fresh list inside.Strings Are Objects and Immutable
Counted, Immutable, and Not NUL-Terminated
A C string ends at the first zero byte, which nothing records and every operation rediscovers. A Python
str stores its length, cannot be modified in place, and holds characters rather than bytes — a distinction with real consequences.#include <stdio.h>
#include <string.h>
int main(void) {
const char *message = "Hello, World!";
printf("%zu\n", strlen(message)); /* a LOOP looking for the zero */
char window[6];
memcpy(window, message, 5);
window[5] = '\0'; /* a substring needs its own buffer */
printf("%s\n", window);
const char with_zero[] = "ab\0cd"; /* ends at the zero: length 2 */
printf("%zu\n", strlen(with_zero));
return 0;
}message = "Hello, World!"
print(len(message)) # a stored length, not a scan
print(message[0:5]) # slicing makes a new string
with_zero = "ab\0cd" # the zero is DATA: length 5
print(len(with_zero))C reports 2 where Python reports 5 — a zero byte terminates one and is ordinary data in the other. The deeper split is that Python's
str is a sequence of Unicode code points while C's char* is bytes: len("é") is 1 in Python and strlen("é") is 2 in UTF-8. Anything crossing into C needs encode(), and bytes is the type that actually corresponds to char*.Reference Counting Instead of free
Nothing to free, and It Is Deterministic
CPython reclaims an object when its reference count reaches zero — which happens at a point you can predict, unlike a tracing collector. The C column writes out the counting by hand to make the mechanism visible.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data;
int refcount;
} Buffer;
static void release(Buffer *buffer) {
buffer->refcount -= 1;
if (buffer->refcount == 0) {
printf("freeing\n");
free(buffer->data);
buffer->data = NULL;
}
}
int main(void) {
Buffer buffer = { malloc(4 * sizeof(int)), 1 };
if (buffer.data == NULL) return 1;
buffer.refcount += 1; /* a second reference */
printf("%d\n", buffer.refcount);
release(&buffer); /* drops to 1 — not freed */
release(&buffer); /* drops to 0 — freed */
return 0;
}import sys
class Buffer:
def __del__(self):
print("freeing")
buffer = Buffer()
alias = buffer # a second reference
# getrefcount reports one extra for its own argument.
print(sys.getrefcount(buffer) - 1)
del alias # drops to 1 — not freed
del buffer # drops to 0 — freed, right hereThe "freeing" line prints at a predictable moment in both columns, and that is CPython's notable property: because it counts references rather than tracing, an object dies at the statement that drops its last reference. It is why
with open(...) closing promptly is reliable in CPython — though relying on it is still discouraged, since a cycle needs the backup tracing collector and other implementations do not count at all. Reference counting is also exactly what a C extension must participate in by hand, with Py_INCREF and Py_DECREF.is Versus ==
is Compares Addresses; == Compares Value
C spells these two comparisons very differently:
== on pointers compares addresses, and comparing contents needs memcmp or strcmp. Python spells them is and ==, which look far more alike than they behave.#include <stdio.h>
#include <string.h>
int main(void) {
int first[3] = { 1, 2, 3 };
int second[3] = { 1, 2, 3 };
/* Comparing the pointers compares ADDRESSES. */
printf("%d\n", first == second);
/* Comparing the contents needs memcmp. */
printf("%d\n", memcmp(first, second, sizeof(first)) == 0);
return 0;
}first = [1, 2, 3]
second = [1, 2, 3]
# 'is' compares IDENTITY — the same question as comparing two pointers.
print(first is second)
# '==' compares VALUE — the same question as memcmp.
print(first == second)A C programmer has the advantage here, because the distinction is already familiar; the hazard is that Python's two spellings are one character apart. The classic bug is
if value is 1000, which is false while if value is 5 is true — CPython caches small integers, so identity accidentally matches for them. Use is only for None, True and False, which are genuinely single objects.Functions, Defaults & Multiple Returns
Returning Two Things
A C function returns one value, so extra results become pointer parameters the caller supplies. Python returns a tuple, and unpacking it on the left of the assignment makes it read like multiple returns.
#include <stdio.h>
/* The extra results are OUT-PARAMETERS. */
static void divide(long numerator, long denominator,
long *quotient, long *remainder) {
*quotient = numerator / denominator;
*remainder = numerator % denominator;
}
int main(void) {
long quotient = 0;
long remainder = 0;
divide(17, 5, "ient, &remainder);
printf("%ld %ld\n", quotient, remainder);
return 0;
}def divide(numerator, denominator):
return numerator // denominator, numerator % denominator
quotient, remainder = divide(17, 5)
print(quotient, remainder)
# divmod does exactly this, in one operation.
print(*divmod(17, 5))The tuple is a real object being allocated and unpacked, so this is a convenience rather than a free abstraction — but it removes the class of bug where a function returns early having written only one of its out-parameters.
divmod is worth knowing for a second reason: the hardware division instruction computes the quotient and remainder together, so asking for both at once is the operation the machine was going to perform anyway.A Struct Becomes a Class or a Tuple
A Struct Becomes a Class
The C idiom is a struct plus functions taking a pointer to it as the first parameter. Python's
@dataclass generates the constructor, the field list and a readable __repr__, and methods take self — the first parameter you were passing by hand, now named.#include <stdio.h>
typedef struct {
long x;
long y;
} Point;
/* The first parameter IS "self". */
static long point_sum(const Point *point) {
return point->x + point->y;
}
int main(void) {
Point point = { 40, 2 };
printf("%ld\n", point_sum(&point));
printf("Point(x=%ld, y=%ld)\n", point.x, point.y);
return 0;
}from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
# "self" is the first parameter, supplied by the call.
def sum(self):
return self.x + self.y
point = Point(40, 2)
print(point.sum())
print(point) # __repr__ is generated: Point(x=40, y=2)The type annotations are the part worth being clear about:
x: int is not enforced at run time — passing a string works and fails later, somewhere else. They exist for readers and for external checkers like mypy. That is the opposite of C, where the declaration is the enforcement, and it is the single biggest adjustment when the compiler stops catching your mistakes.Exceptions Instead of Return Codes
Raising Versus Returning a Status
C signals failure with a sentinel return plus a global
errno that the next call may overwrite. Python raises an object that unwinds the stack, runs every finally on the way, and carries a type and a message.#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
int main(void) {
const char *text = "123";
char *end = NULL;
errno = 0; /* MUST be cleared first */
long value = strtol(text, &end, 10);
if (errno != 0 || end == text || *end != '\0') {
printf("error: bad number\n");
return 1;
}
printf("%ld\n", value);
const char *bad = "nope";
char *bad_end = NULL;
errno = 0;
long other = strtol(bad, &bad_end, 10);
(void) other;
/* No errno for "not a number" — the only signal is that the end
pointer did not move. This is the trap. */
if (bad_end == bad || *bad_end != '\0') {
printf("error: bad number\n");
}
return 0;
}print(int("123"))
try:
int("nope")
except ValueError:
print("error: bad number")The C column is not padded:
strtol really does require clearing errno first, and really does report "not a number at all" only by leaving the end pointer where it started. What Python adds beyond brevity is that failure cannot be silently ignored — an unhandled exception stops the program with a traceback, where an unchecked C return value simply continues with a wrong number.A Hash Table in the Language
A Hash Table You Did Not Write
C's standard library has no map, so a small table is usually an array of pairs and a loop. Python's
dict is a hash table built into the language with its own literal syntax, and since 3.7 it preserves insertion order.#include <stdio.h>
#include <string.h>
typedef struct {
const char *key;
long value;
} Entry;
static int lookup(const Entry *entries, size_t count,
const char *key, long *out) {
for (size_t index = 0; index < count; index++) {
if (strcmp(entries[index].key, key) == 0) {
*out = entries[index].value;
return 1;
}
}
return 0;
}
int main(void) {
Entry ages[3] = { { "alice", 30 }, { "bob", 25 }, { "carol", 41 } };
size_t count = 3;
long age = 0;
if (lookup(ages, count, "bob", &age)) printf("bob %ld\n", age);
long ignored = 0;
if (!lookup(ages, count, "dave", &ignored)) printf("dave not found\n");
printf("%zu\n", count);
return 0;
}ages = {"alice": 30, "bob": 25, "carol": 41}
if "bob" in ages:
print("bob", ages["bob"])
if "dave" not in ages:
print("dave not found")
print(len(ages))The C version is a linear scan — correct for three entries, wrong for three thousand — and choosing when to build a real hash table is a decision Python made once for everyone. The
in operator is the "found or not" question the C function answers through its return value, and ages.get(key, default) is the version that hands back a fallback instead, which is what the out-parameter was doing.Where They Meet: ctypes and Extensions
ctypes Restates the C Prototype
This is the reader's practical destination.
ctypes loads a shared library and calls into it with no compilation step, but nothing is checked for you: you restate the argument and return types in Python, and getting them wrong corrupts the stack rather than raising. The Python column is illustrative — there is no library to load in the runner — while the C column runs.#include <stdio.h>
/* The exported function ctypes would bind to. */
long combine(long first, long second, long third) {
return first + second * third;
}
int main(void) {
printf("%ld\n", combine(2, 5, 8));
return 0;
}# What binding to the C function beside this looks like.
#
# import ctypes
# library = ctypes.CDLL("./libcombine.so")
#
# # These two lines are the whole safety net, and they are OPTIONAL —
# # omit them and ctypes assumes int, which truncates a 64-bit long.
# library.combine.argtypes = [ctypes.c_long] * 3
# library.combine.restype = ctypes.c_long
#
# print(library.combine(2, 5, 8))
print("declaration only")The
argtypes/restype lines are the part people skip, and skipping them is how a 64-bit return value silently becomes 32 bits — ctypes defaults to int for anything you do not declare. The other route is a real extension module, which is faster and far more work: it means writing against Python.h, managing reference counts by hand with Py_INCREF/Py_DECREF, and deciding when to release the GIL. cffi sits between the two by parsing the actual header.