PONYλM2Modula-2

C.CodeCompared.To/Python

An interactive executable cheatsheet comparing C and Python

C17 (GCC) Python 3.14
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.
print Adds Things printf Does Not
A C programmer reads print as printf with the format string inferred. It is closer to fputs with a separator and a terminator built in, and both are yours to change.
#include <stdio.h> int main(void) { /* printf writes exactly what you give it, and nothing else. */ printf("%d %s %.2f\n", 3, "apples", 1.5); /* Several values on one line means writing the spaces yourself. */ for (int index = 0; index < 3; index++) { printf("%d", index); if (index < 2) printf(", "); } printf("\n"); /* stdout is line-buffered on a terminal and BLOCK-buffered on a pipe. */ fflush(stdout); return 0; }
# print inserts a space between its arguments and a newline at the end. print(3, "apples", f"{1.5:.2f}") # sep and end are the two knobs, so the loop above is one call. print(*range(3), sep=", ") # Same buffering rule, same escape hatch. print("flushed now", flush=True)
The two defaults are what to remember: sep=" " between arguments and end="\n" after the last one, both overridable per call. print(*range(3)) spreads a sequence into separate arguments, which is why the comma-joining loop collapses to one line. The buffering behaves exactly as C's does — line-buffered to a terminal, block-buffered to a pipe — so a program that crashes after printing can lose its last output in both languages, and flush=True is fflush(stdout). For the C-style spelling there is still sys.stdout.write, which adds nothing at all.
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.
Swapping Without a Temporary
Because a name is a label rather than storage, several names can be rebound at once — and the right-hand side is fully evaluated before any of it happens.
#include <stdio.h> int main(void) { int first = 1, second = 2; /* Two slots, so moving between them needs a third. */ int temporary = first; first = second; second = temporary; printf("%d %d\n", first, second); /* Returning two values means out-parameters or a struct. */ int quotient = 17 / 5, remainder = 17 % 5; printf("%d %d\n", quotient, remainder); return 0; }
first, second = 1, 2 # The right side is built first, THEN the names are bound — so no # temporary is needed and no order of assignment matters. first, second = second, first print(first, second) quotient, remainder = divmod(17, 5) print(quotient, remainder)
The rule that makes this safe is worth stating exactly: the entire right-hand side is evaluated to a tuple, and only then are the names on the left bound to its parts. So a, b = b, a cannot go wrong the way a = b; b = a does, and neither can values[i], values[j] = values[j], values[i]. The same unpacking works on any iterable and nests — (name, (x, y)) = record — and first, *rest = values takes the head and keeps the tail, which is the closest Python comes to pointer arithmetic on a list.
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
Getting Fixed Width Back
Arbitrary precision is the right default and it is the wrong answer when you are reproducing what a 32-bit register does. Masking is how you get C's behavior back.
#include <stdio.h> #include <stdint.h> int main(void) { /* A hash that relies on wrapping at 32 bits. */ uint32_t hash = 2166136261u; const char *text = "hello"; for (const char *cursor = text; *cursor; cursor++) { hash ^= (uint32_t)(unsigned char)*cursor; hash *= 16777619u; /* wraps, by definition */ } printf("%u\n", hash); /* Shifting right is arithmetic for signed, logical for unsigned. */ printf("%d %u\n", -8 >> 1, (unsigned)-8 >> 28); return 0; }
MASK = 0xFFFFFFFF hash_value = 2166136261 for byte in b"hello": hash_value ^= byte hash_value = (hash_value * 16777619) & MASK # the & IS the wrapping print(hash_value) # Python integers are conceptually infinite two's complement, so >> on a # negative number is arithmetic and there is no unsigned shift at all. print(-8 >> 1, ((-8) & MASK) >> 28)
🚨 & 0xFFFFFFFF after every operation is not an optimization, it is the semantics: leave one out and the value silently grows past 32 bits and every later result is wrong. That is the single most common way a hash or a checksum ported from C gives different answers in Python. Note also that iterating b"hello" yields integers while iterating "hello" yields one-character strings — the bytes form is what corresponds to unsigned char *. When the arithmetic really has to be fixed width, numpy.uint32 or the ctypes integer types wrap on their own.
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 -2
The 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
Slicing Instead of Pointer Arithmetic
Every pointer + offset, memcpy of a range, and reverse-iterating loop has a slice that says the same thing — and a slice always copies.
#include <stdio.h> #include <string.h> int main(void) { int values[6] = {0, 1, 2, 3, 4, 5}; /* A "slice" is a pointer and a length, with no copy. */ int *middle = values + 1; for (int index = 0; index < 3; index++) printf("%d ", middle[index]); printf("\n"); /* A real copy is explicit. */ int copied[3]; memcpy(copied, values + 2, 3 * sizeof(int)); copied[0] = 99; printf("%d %d\n", values[2], copied[0]); /* Backwards needs the loop written backwards. */ for (int index = 5; index >= 0; index--) printf("%d", values[index]); printf("\n"); return 0; }
values = [0, 1, 2, 3, 4, 5] # start:stop, stop exclusive — and this ALLOCATES a new list. print(*values[1:4]) copied = values[2:5] copied[0] = 99 print(values[2], copied[0]) # The third part is a stride, and -1 walks backwards. print("".join(str(value) for value in values[::-1]))
🚨 The performance consequence is the one to internalize: values[1:] inside a loop copies the whole tail every time, so a C idiom that advances a pointer becomes quadratic when it is transcribed directly. The fixes are itertools.islice, an explicit index, or memoryview for a buffer that really must not be copied. Everything else about the notation is friendlier than the pointer version: the bounds are checked, values[2:99] clamps instead of reading past the end, and a negative index counts from the far end so values[-1] is the last element.
When You Need a Real Buffer
A list of a million integers is a million pointers to a million objects. When the memory layout is the point — a file header, a socket frame, something crossing into C — Python has types that are genuinely contiguous.
#include <stdio.h> #include <string.h> int main(void) { /* Contiguous, one machine word each, no indirection. */ unsigned char frame[8]; memset(frame, 0, sizeof frame); frame[0] = 0x7E; frame[1] = 4; memcpy(frame + 2, "data", 4); for (size_t index = 0; index < sizeof frame; index++) printf("%02X", frame[index]); printf("\n"); /* A window onto part of it, still with no copy. */ unsigned char *payload = frame + 2; printf("%.4s\n", payload); return 0; }
# bytearray is a mutable, contiguous block of bytes. frame = bytearray(8) frame[0] = 0x7E frame[1] = 4 frame[2:6] = b"data" print(frame.hex().upper()) # memoryview is the pointer-into-the-same-memory: no copy, and a write # through it is visible in the original. payload = memoryview(frame)[2:6] print(bytes(payload).decode())
The four to know, in order of how close they are to C. bytes is a const char *; bytearray is a char * you may write through; memoryview is a pointer into an existing buffer, so slicing one costs nothing and mutating through it mutates the original; and array.array("i", …) is a real int[] when the elements are not bytes. All four implement the buffer protocol, which is what lets ctypes, numpy and a C extension take them without copying. A plain list implements none of it, which is exactly why it cannot be handed to C.
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
🚨 char * Is bytes, Not str
This is the single most important row on the page for anyone moving data between the two languages. C's char * is a sequence of bytes. Python's str is a sequence of Unicode code points. They are different types and Python will not convert between them for you.
#include <stdio.h> #include <string.h> int main(void) { /* strlen counts BYTES, and says nothing about characters. */ const char *text = "café"; printf("%zu bytes\n", strlen(text)); /* Indexing gives you a byte, which may be half a character. */ printf("%d\n", (unsigned char)text[3]); /* There is no encoding here at all — just whatever the source file had. */ for (size_t index = 0; index < strlen(text); index++) printf("%02X ", (unsigned char)text[index]); printf("\n"); return 0; }
text = "café" print(len(text), "characters") # 4 — code points, not bytes encoded = text.encode("utf-8") # str -> bytes, ALWAYS explicit print(len(encoded), "bytes") print(encoded[3]) # an int, the way C indexing gives a byte print(" ".join(f"{byte:02X}" for byte in encoded)) # And back. A wrong encoding raises rather than producing mojibake. print(encoded.decode("utf-8"))
🚨 The rule: decode at the edge, work in str, encode at the edge. Anything arriving from a socket, a file opened in binary mode, or a C function is bytes and must be decoded before it is text. The two failures to recognize are UnicodeDecodeError — bytes that are not valid in the encoding you claimed — and TypeError: can't concat str to bytes, which is Python refusing to guess. ctypes.c_char_p hands you bytes for exactly this reason, and sys.getdefaultencoding() is UTF-8 everywhere, so the encoding argument is usually the one you want anyway.
Building a String Without strcat
Repeated concatenation is quadratic in both languages and only one of them makes that obvious. join is the linear answer, and it is the idiom rather than an optimization.
#include <stdio.h> #include <string.h> int main(void) { const char *words[] = {"alpha", "beta", "gamma"}; char line[64] = ""; /* strcat rescans from the start every time — quadratic, and it is also where the buffer overflows live. */ for (int index = 0; index < 3; index++) { if (index > 0) strcat(line, ", "); strcat(line, words[index]); } printf("%s\n", line); /* snprintf into a tracked offset is the safe version. */ char safe[64]; int offset = 0; for (int index = 0; index < 3; index++) offset += snprintf(safe + offset, sizeof safe - offset, index ? ", %s" : "%s", words[index]); printf("%s\n", safe); return 0; }
words = ["alpha", "beta", "gamma"] # One allocation: join measures the total first, then fills it. print(", ".join(words)) # The quadratic version is legal and looks innocent. line = "" for index, word in enumerate(words): if index: line += ", " line += word print(line) # Growing piecewise: collect into a list, join once at the end. parts = [word.upper() for word in words] print(" | ".join(parts))
Strings are immutable, so line += word allocates a new string and copies everything so far — the same rescanning strcat does, without the buffer overflow. CPython has a special case that sometimes makes it linear when the string has exactly one reference, and it is an implementation detail that disappears the moment anything else holds the value. The reliable shapes are "".join(parts), an f-string when the pieces are known, and io.StringIO when they arrive over a long stretch of code. Note join is a method on the separator, which reads backwards exactly once.
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
Where Reference Counting Is Not Enough
Reference counting frees an object the moment its last name goes away, which is why Python can be deterministic where a tracing collector cannot. It cannot free a cycle, so there is a second collector underneath.
#include <stdio.h> #include <stdlib.h> typedef struct Node { struct Node *other; const char *name; } Node; int main(void) { Node *first = malloc(sizeof *first); Node *second = malloc(sizeof *second); first->name = "first"; first->other = second; second->name = "second"; second->other = first; printf("%s <-> %s\n", first->name, second->other->name); /* A cycle is nothing special: you free what you allocated. */ free(first); free(second); printf("freed\n"); return 0; }
import gc class Node: def __init__(self, name): self.name = name self.other = None def __del__(self): print(f"freeing {self.name}") first = Node("first") second = Node("second") first.other = second second.other = first # a cycle: each keeps the other's count at 1 print(f"{first.name} <-> {second.other.name}") del first, second # both counts drop to 1, so NOTHING is freed print("names dropped") print(gc.collect(), "objects collected by the cycle detector")
The output makes the mechanism visible: dropping both names prints nothing, and the destructors only run when gc.collect() walks the heap and finds the pair unreachable. In ordinary code the cycle detector runs on its own and you never think about it. Two places you do: an object holding a large buffer or an operating-system handle is freed later than you expect if it is in a cycle, which is what with and weakref are for; and a __del__ is not a destructor you may rely on for cleanup, for the same reason. 🚨 It is also why Py_INCREF/Py_DECREF must balance exactly in an extension module — one extra increment leaks the object permanently, and one too few is a use-after-free that crashes somewhere else entirely.
goto cleanup Becomes with
C's answer to "release these three things however the function ends" is a single exit label and a chain of gotos. Python's is a block whose exit is guaranteed, and it composes.
#include <stdio.h> #include <stdlib.h> static int work(int fail_at) { char *buffer = NULL; char *second = NULL; int status = 1; buffer = malloc(16); if (!buffer) goto cleanup; printf("acquired buffer\n"); if (fail_at == 1) goto cleanup; second = malloc(16); if (!second) goto cleanup; printf("acquired second\n"); if (fail_at == 2) goto cleanup; status = 0; cleanup: free(second); free(buffer); printf("released\n"); return status; } int main(void) { printf("status %d\n", work(2)); return 0; }
from contextlib import contextmanager @contextmanager def resource(name): print(f"acquired {name}") try: yield name finally: print(f"released {name}") def work(fail_at): # Both are released in reverse order however this block ends. with resource("buffer"), resource("second"): if fail_at == 2: raise RuntimeError("failed") return 0 try: work(2) except RuntimeError: print("status 1")
The property they share is that cleanup runs on every path out, including the error one. What with adds is that the obligation lives in the resource rather than in every function that uses it: writing with open(name) as handle makes it impossible to forget fclose, where the goto cleanup chain has to be rebuilt correctly in each new function, in the right order, with the right guards for the partially-acquired case. contextlib.ExitStack covers the case C solves with a loop over an array of handles — a variable number of things to release.
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 here
The "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 ==
Why is Sometimes Looks Like It Works
🚨 is compares addresses, and CPython reuses objects for small integers and some strings. So is gives the right answer often enough to look correct and is still wrong.
#include <stdio.h> #include <string.h> int main(void) { /* Identical literals MAY share storage — it is up to the compiler. */ const char *first = "hello"; const char *second = "hello"; printf("%d\n", first == second); /* address comparison */ /* A built string never shares. */ char built[6]; strcpy(built, "hello"); printf("%d\n", built == first); printf("%d\n", strcmp(built, first) == 0); /* the value question */ return 0; }
# Small integers (-5..256) are preallocated and shared. print(256 is 256 - 0, 257 is 257 - 0) first = "hello" second = "hello" print(first is second) # True: the compiler interned both literals built = "".join(["hel", "lo"]) print(built is first) # False: built at run time print(built == first) # True: the question you meant to ask
The rule with no exceptions: is for None, True, False and sentinel objects you created yourself; == for everything else. Which values happen to be shared is a CPython implementation detail that differs between versions and between interpreters, so code that passes its tests on integers below 257 fails on a larger one. Python 3.8 and later emit a SyntaxWarning for is against a literal, which catches most of it. sys.intern forces sharing deliberately, and is worth it only for a large number of repeated dictionary keys.
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
🚨 A Default Argument Is Evaluated Once
The most notorious trap in the language, and it follows directly from the model this page keeps returning to: the default is an object, created when the def runs, and every call that omits the argument gets that same object.
#include <stdio.h> /* C has no default arguments at all, so the equivalent mistake is a static local: one buffer, shared by every call. */ static int *appended(int value) { static int storage[8]; static int count = 0; storage[count++] = value; printf("count now %d\n", count); return storage; } int main(void) { appended(1); appended(2); /* the same storage, still holding the 1 */ return 0; }
def appended(value, into=[]): # 🚨 this list is created ONCE into.append(value) return into print(appended(1)) print(appended(2)) # [1, 2] — the same list, still holding the 1 def fixed(value, into=None): # the correct spelling if into is None: into = [] into.append(value) return into print(fixed(1)) print(fixed(2))
A C programmer has the right instinct for this one already, because it is a static local wearing a different hat: the storage is created once and outlives the call. The default is evaluated when the def statement runs — once, at import — and stored on the function object, where you can even see it as appended.__defaults__. Immutable defaults (0, None, "", a tuple) are safe because nothing can change them, which is why None plus a check is the universal idiom. Every linter flags this, and it still ships regularly.
Varargs Without va_list
C's ... gives the callee no idea how many arguments arrived or what type they are — which is why printf needs the format string. Python's *args is an ordinary tuple.
#include <stdio.h> #include <stdarg.h> /* The count has to be passed separately: va_list cannot be measured. */ static int total(int count, ...) { va_list arguments; va_start(arguments, count); int sum = 0; for (int index = 0; index < count; index++) sum += va_arg(arguments, int); /* the type is your promise */ va_end(arguments); return sum; } int main(void) { printf("%d\n", total(3, 1, 2, 3)); return 0; }
def total(*values, scale=1): # values is a real tuple: it knows its own length and its contents. return sum(values) * scale print(total(1, 2, 3)) print(total(1, 2, 3, scale=10)) numbers = [1, 2, 3] print(total(*numbers)) # unpack a sequence into arguments def describe(**options): # And keyword arguments arrive as a dict. return ", ".join(f"{key}={value}" for key, value in sorted(options.items())) print(describe(width=3, height=4))
The asterisk means "spread" in both directions, which is the piece to get used to: in a parameter list *values collects the extra positional arguments into a tuple, and at a call site f(*values) spreads a sequence back out. ** does the same for keyword arguments and a dict. Because the tuple is a real object there is no format string to keep in step with the arguments and no undefined behavior when you promise int and pass a double — the mismatch C's va_arg cannot detect and -Wformat exists to catch.
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, &quotient, &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 Function Pointer, Without the Syntax
C already has first-class functions in the sense that matters — a function pointer in a struct is how you build a vtable by hand. Python has the same thing with no declaration to read backwards.
#include <stdio.h> typedef struct { const char *name; int (*apply)(int); } Operation; static int doubled(int value) { return value * 2; } static int squared(int value) { return value * value; } int main(void) { Operation operations[] = { {"doubled", doubled}, {"squared", squared}, }; for (size_t index = 0; index < 2; index++) printf("%s(5) = %d\n", operations[index].name, operations[index].apply(5)); return 0; }
def doubled(value): return value * 2 def squared(value): return value * value # A function is an object, so it goes in a dict like anything else. operations = {"doubled": doubled, "squared": squared} for name, apply in operations.items(): print(f"{name}(5) = {apply(5)}") # A closure captures its enclosing variables, which a C function pointer # cannot do — this is what the "context" void * exists to work around. def scaled_by(factor): return lambda value: value * factor print(scaled_by(3)(5))
The dispatch table is the same design in both columns, and the difference is what a function can carry. A C function pointer is an address and nothing else, so every callback API grows a void *context parameter to smuggle state alongside it — qsort_r, every GUI toolkit, every thread entry point. A Python function closes over the variables where it was defined, so the state travels with it and no such parameter exists. functools.partial is the explicit version when a closure would be overkill.
When the Byte Layout Matters
A dataclass is the readable replacement for a struct, and it says nothing about memory. When the layout is the contract — a file header, a wire protocol — the struct module is the translation.
#include <stdio.h> #include <string.h> #include <stdint.h> /* Field order, sizes and endianness are the file format. */ struct Header { uint16_t magic; uint16_t version; uint32_t length; }; int main(void) { struct Header header = {0x7E7E, 1, 260}; unsigned char bytes[8]; memcpy(bytes, &header, sizeof header); for (size_t index = 0; index < sizeof header; index++) printf("%02X", bytes[index]); printf("\n"); printf("%zu\n", sizeof(struct Header)); return 0; }
import struct # "<HHI": little-endian, two uint16, one uint32. The format string IS # the struct declaration, and it has no padding unless you ask. LAYOUT = "<HHI" packed = struct.pack(LAYOUT, 0x7E7E, 1, 260) print(packed.hex().upper()) print(struct.calcsize(LAYOUT)) magic, version, length = struct.unpack(LAYOUT, packed) print(hex(magic), version, length)
The two things a C programmer must supply explicitly here are the ones the compiler decided silently: byte order and padding. < is little-endian, > big-endian, ! network order, and a leading = or @ asks for native — where @ also reinstates the alignment padding a C compiler would insert, which is the only mode where calcsize matches sizeof on a struct with mixed field widths. Getting this wrong is how a parser reads correctly on one machine and returns nonsense on another; writing the format string out makes it a decision rather than an assumption.
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
Catching by Type, Not by Value
A C error code is an integer you compare against a list. A Python exception is an object in a class hierarchy, so a single except can catch a whole family of failures.
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> static int parse_port(const char *text, int *out) { errno = 0; char *end; long value = strtol(text, &end, 10); if (end == text || *end) return EINVAL; /* not a number */ if (errno == ERANGE) return ERANGE; /* out of long's range */ if (value < 1 || value > 65535) return ERANGE; /* out of OUR range */ *out = (int)value; return 0; } int main(void) { int port; /* Every caller repeats this ladder, and forgetting one arm is silent. */ for (int index = 0; index < 2; index++) { const char *text = index ? "99999" : "abc"; int status = parse_port(text, &port); if (status) printf("%s: %s\n", text, strerror(status)); else printf("%s: %d\n", text, port); } return 0; }
class PortError(ValueError): """Anything wrong with a port. ValueError, so existing handlers see it.""" class PortNotANumber(PortError): pass class PortOutOfRange(PortError): pass def parse_port(text): try: value = int(text) except ValueError: raise PortNotANumber(f"{text!r} is not a number") from None if not 1 <= value <= 65535: raise PortOutOfRange(f"{value} is outside 1..65535") return value for text in ("abc", "99999"): try: print(f"{text}: {parse_port(text)}") except PortError as error: # one arm catches both subclasses print(f"{text}: {error}")
The hierarchy is what the error code cannot do: except PortError catches both subclasses, except ValueError catches them plus anything else that means "bad value", and a caller that wants to tell the two apart still can. The failure also cannot be ignored — an unhandled exception stops the program, where an unchecked return code carries on with a garbage value, which is the single largest class of C bug this replaces. raise … from None suppresses the chained ValueError so the message says what the caller cares about, and raise … from error keeps it when the cause is worth showing.
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 Set Instead of a Flag Array
The C answer to "have I seen this already" is a flag array indexed by the value, or a bitmap, or a sorted array plus bsearch. Python has a hash set in the language.
#include <stdio.h> #include <string.h> int main(void) { int values[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3}; size_t count = sizeof values / sizeof *values; /* A flag array works only because the range is small and known. */ unsigned char seen[16]; memset(seen, 0, sizeof seen); int unique[16]; size_t unique_count = 0; for (size_t index = 0; index < count; index++) { if (seen[values[index]]) continue; seen[values[index]] = 1; unique[unique_count++] = values[index]; } printf("%zu unique\n", unique_count); for (size_t index = 0; index < unique_count; index++) printf("%d ", unique[index]); printf("\n"); return 0; }
values = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3] seen = set() unique = [] for value in values: if value in seen: continue seen.add(value) unique.append(value) print(len(unique), "unique") print(*unique) # And when the order does not matter, the loop is not needed at all. print(sorted(set(values))) print(set(values) & {1, 2, 3}, set(values) - {1, 2, 3})
The flag array is faster when the range really is small and dense, and it stops working the moment the values are sparse, negative, or strings — where the C answer becomes a hash table you write yourself. A set has no such condition: membership is average constant time for anything hashable, and &, |, - and ^ are intersection, union, difference and symmetric difference. 🚨 A set is unordered, so set(values) alone loses the first-seen order the loop above preserves — dict.fromkeys(values) deduplicates and keeps it, since dictionaries have been insertion-ordered since Python 3.7.
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
The Other Direction: a C Extension
ctypes calls an existing library from Python. An extension module is the reverse — C compiled so that Python can import it — and it is what you write when the C has to be the fast inner loop.
/* Illustrative: this compiles against Python.h and is loaded by import, so there is nothing for a standalone run to do. */ #define PY_SSIZE_T_CLEAN #include <Python.h> static PyObject *fast_sum(PyObject *self, PyObject *args) { PyObject *sequence; if (!PyArg_ParseTuple(args, "O", &sequence)) return NULL; Py_ssize_t length = PySequence_Size(sequence); if (length < 0) return NULL; long total = 0; for (Py_ssize_t index = 0; index < length; index++) { /* GetItem returns a NEW reference — releasing it is your job. */ PyObject *item = PySequence_GetItem(sequence, index); if (!item) return NULL; total += PyLong_AsLong(item); Py_DECREF(item); } return PyLong_FromLong(total); } static PyMethodDef methods[] = { {"fast_sum", fast_sum, METH_VARARGS, "Sum a sequence of ints."}, {NULL, NULL, 0, NULL}, }; static struct PyModuleDef module = { PyModuleDef_HEAD_INIT, "accelerate", NULL, -1, methods, }; PyMODINIT_FUNC PyInit_accelerate(void) { return PyModule_Create(&module); }
# What the module above looks like from this side. Building it is a # setup.py with an Extension("accelerate", ["accelerate.c"]) entry, and # then it is an ordinary import: # # import accelerate # accelerate.fast_sum([1, 2, 3]) # -> 6 # # The pure-Python equivalent, which is what you are trying to beat: def fast_sum(sequence): total = 0 for item in sequence: total += item return total print(fast_sum([1, 2, 3])) print(sum([1, 2, 3])) # already C, and already faster than the loop
🚨 The whole difficulty of the C side is in the Py_DECREF. PySequence_GetItem returns a new reference and PyList_GetItem returns a borrowed one, and getting that distinction wrong is a leak in one direction and a crash in the other — the documentation states it per function because there is no way to tell from the signature. Two things worth knowing before starting: sum, sorted and the rest of the built-ins are already C, so the pure-Python loop is rarely the real baseline; and Cython, cffi and pybind11 all generate this layer for you, with the reference counting handled.
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.
#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.

Thank you — anything else?