Python Bindings: Connecting Python to C/C++
Python is quick to write but slow to run. C and C++ are the opposite. In this notebook you'll learn the main ways to call C and C++ code from Python, so you can get the best of both: write most of your program in Python and hand the heavy lifting to compiled code.
Learning Objectives
By the end of this notebook you will be able to:
- Explain what Python bindings are and why libraries like NumPy use them
- Call C functions from Python with
ctypes - Use
cffiin ABI mode (load a library) and API mode (compile C code) - Write a small C extension module with the Python C API
- Expose C++ functions and classes to Python with
pybind11 - Speed up Python code with Cython type declarations
- Pass structs and callbacks between Python and C
- Pick the right binding approach for a given job
Prerequisites
- Python 3.10 or newer, and comfort with functions and classes
- Basic C: types, pointers, arrays and how to compile a file
- A C/C++ compiler:
- macOS: run
xcode-select --install. This gives youclang; thegccandc++commands used below are aliases for it. - Linux: install
gcc,g++and the Python headers, e.g.sudo apt install build-essential python3-dev. - Windows: the easiest path is WSL (then follow the Linux steps). For native Windows you need MinGW-w64
gccfor thectypes/cffiexamples and the "Microsoft C++ Build Tools" for extension modules. The!gcc/!c++commands here are written for macOS and Linux.
- macOS: run
- Python packages:
cffi,pybind11,cythonandsetuptools(the install cells below take care of them)
How to Use This Notebook
- Run the cells from top to bottom. Later cells reuse libraries and variables built earlier (
lib_name,ctypes_lib, ...). - Cells starting with
%%writefilesave C/C++ source to disk; the cell after usually compiles it. All generated files land in the notebook's folder, and the Cleanup section at the end removes them. - Try it and Exercise cells are for you. Write your answer first, then open Show solution to compare.
- Each section ends with a Quick recap. Read only the recaps when revising before an exam.
Why Python Bindings?
Python is easy to write, but a pure Python loop is often 10 to 100 times slower than the same loop in C. A binding is the glue that lets Python call code written in another language. With bindings you can:
- Speed up the slow parts of a program (number crunching, image processing)
- Reuse existing C/C++ libraries instead of rewriting them (OpenSSL, SQLite, system APIs)
- Reach hardware and operating-system features that pure Python can't
- Wrap an old C codebase in a friendly Python interface
Real-World Examples
You've probably already used bindings without noticing:
| Python Library | Underlying C/C++ Code |
|---|---|
| NumPy | C and Fortran numerical routines |
| TensorFlow | C++ computation engine |
| Pillow (PIL) | C image decoding libraries |
| sqlite3 | SQLite C library |
| OpenCV (cv2) | C++ computer vision library |
How Python Talks to C/C++
Your Python Code
|
v
[Binding Layer] <-- ctypes, cffi, C API, pybind11, Cython
|
v
Compiled C/C++ Code (.so / .dll / .dylib)
|
v
Hardware (CPU, GPU, etc.)
The binding layer's job is translation: it turns Python objects into C values on the way in, and C values back into Python objects on the way out.
Overview of Binding Approaches
| Approach | Language | Difficulty | Use Case |
|---|---|---|---|
ctypes |
C | Easy | Call existing shared libraries |
cffi |
C | Easy-Medium | Modern alternative to ctypes |
| Python C API | C | Hard | Maximum control, build extension modules |
pybind11 |
C++ | Medium | Modern C++ bindings with clean syntax |
| Cython | C/C++ | Medium | Write C extensions using Python-like syntax |
| SWIG | C/C++ | Medium | Auto-generate bindings for multiple languages |
We'll go through them roughly in this order, from "no compiling Python-side at all" to "build a real extension module".
Quick recap
- A binding lets Python call compiled C/C++ code.
- We use bindings for speed, to reuse existing libraries, and to reach OS/hardware features.
- Many popular libraries (NumPy, Pillow, sqlite3) are thin Python layers over C or C++.
1. ctypes: Calling C from Python
ctypes ships with Python, so there's nothing to install. It loads a shared library (.so on Linux, .dylib on macOS, .dll on Windows) and lets you call its functions directly.
Things to know before we start:
- The C code must already be compiled.
ctypesdoesn't compile anything for you. - You have to tell
ctypesthe argument types and return type of each function. It can't read C header files. - If you get the types wrong, you get wrong answers or a crash, not a friendly error.
1.1 Creating a Simple C Library
First we need a C library to call. The next cell saves a small C file with five functions; the one after compiles it into a shared library.
%%writefile mylib.c
#include <stdio.h>
#include <math.h>
// A simple function: add two integers
int add(int a, int b) {
return a + b;
}
// Multiply two doubles
double multiply(double a, double b) {
return a * b;
}
// Compute factorial iteratively
long long factorial(int n) {
long long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// Modify an array in-place: square each element
void square_array(double* arr, int size) {
for (int i = 0; i < size; i++) {
arr[i] = arr[i] * arr[i];
}
}
// Return a greeting string
const char* greet(const char* name) {
static char buffer[256];
snprintf(buffer, sizeof(buffer), "Hello, %s! Welcome from C.", name);
return buffer;
}
Writing mylib.c
Now compile it. The command is slightly different on each operating system, so we pick it with platform.system() and remember the file name in lib_name for later cells.
On macOS and Linux this is the same as typing gcc -shared -fPIC -o mylib.so mylib.c in a terminal. -shared asks for a library instead of a program, and -fPIC makes the code work no matter where in memory it gets loaded.
import subprocess
import sys
import platform
# Step 2: Compile the C code into a shared library
if platform.system() == "Darwin": # macOS
print("Compiling for macOS...")
cmd = ["gcc", "-shared", "-o", "mylib.dylib", "-fPIC", "mylib.c"]
lib_name = "mylib.dylib"
elif platform.system() == "Linux":
print("Compiling for Linux...")
cmd = ["gcc", "-shared", "-o", "mylib.so", "-fPIC", "mylib.c"]
lib_name = "mylib.so"
else: # Windows
print("Compiling for Windows...")
cmd = ["gcc", "-shared", "-o", "mylib.dll", "mylib.c"]
lib_name = "mylib.dll"
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"Compiled successfully: {lib_name}")
else:
print(f"Compilation failed:\n{result.stderr}")
Compiling for macOS...
Compiled successfully: mylib.dylib
1.2 Loading and Calling C Functions with ctypes
ctypes.CDLL loads the library. After that, every C function is available as an attribute of lib.
import ctypes
import os
# Load the shared library
lib = ctypes.CDLL(os.path.abspath(lib_name))
# --- Calling add(int, int) -> int ---
# By default, ctypes assumes every argument and the return value are C ints
result = lib.add(10, 25)
print(f"add(10, 25) = {result}")
# factorial really returns a long long, but ctypes doesn't know that yet.
# For small results the int guess happens to work...
print(f"factorial(5) = {lib.factorial(5)}")
add(10, 25) = 35
factorial(5) = 120
add worked with no setup because "everything is an int" is exactly what ctypes assumes. As soon as a function uses other types, you must spell them out with argtypes (a list of argument types) and restype (the return type).
# --- Calling multiply(double, double) -> double ---
# For non-int types, we MUST specify argtypes and restype
lib.multiply.argtypes = [ctypes.c_double, ctypes.c_double]
lib.multiply.restype = ctypes.c_double
result = lib.multiply(3.14, 2.0)
print(f"multiply(3.14, 2.0) = {result}")
multiply(3.14, 2.0) = 6.28
# --- Calling factorial(int) -> long long ---
lib.factorial.argtypes = [ctypes.c_int]
lib.factorial.restype = ctypes.c_longlong
for n in [5, 10, 20]:
print(f"factorial({n}) = {lib.factorial(n)}")
factorial(5) = 120
factorial(10) = 3628800
factorial(20) = 2432902008176640000
What just happened? factorial(20) is about 2.4 × 10¹⁸, far too big for a 32-bit int. Because we set restype = ctypes.c_longlong, ctypes reads all 64 bits of the answer. Without that line, factorial(20) would silently come back as a wrong, truncated number. That's why the rule is: always set argtypes and restype, even when the default seems to work.
1.3 Working with Pointers and Arrays
A C function that takes double* wants the address of a block of doubles. In ctypes, ctypes.c_double * 5 is a type meaning "array of 5 doubles", and calling it creates one.
# --- Calling square_array(double*, int) -> void ---
# We need to create a C-compatible array from Python
lib.square_array.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int]
lib.square_array.restype = None # void return
# Create a C array of 5 doubles
ArrayType = ctypes.c_double * 5
data = ArrayType(1.0, 2.0, 3.0, 4.0, 5.0)
print(f"Before: {list(data)}")
lib.square_array(data, 5)
print(f"After: {list(data)}")
# The C function modified our array in-place!
Before: [1.0, 2.0, 3.0, 4.0, 5.0]
After: [1.0, 4.0, 9.0, 16.0, 25.0]
The C function changed our array in place: C received a pointer to the same memory that data uses, so no copy came back. This is how most C functions return large results.
1.4 Working with Strings
C strings are just bytes ending in a zero byte, so on the Python side we pass bytes (b"..." or .encode()) and decode what comes back.
# --- Calling greet(const char*) -> const char* ---
lib.greet.argtypes = [ctypes.c_char_p]
lib.greet.restype = ctypes.c_char_p
# C strings are bytes in Python, so we encode
name = "Aayush".encode("utf-8")
greeting = lib.greet(name)
print(greeting.decode("utf-8"))
Hello, Aayush! Welcome from C.
1.5 ctypes Type Mapping Reference
| C Type | ctypes Type |
Python Type |
|---|---|---|
int |
c_int |
int |
long |
c_long |
int |
long long |
c_longlong |
int |
size_t |
c_size_t |
int |
float |
c_float |
float |
double |
c_double |
float |
char |
c_char |
bytes (1 char) |
char* |
c_char_p |
bytes or None |
void* |
c_void_p |
int or None |
int* |
POINTER(c_int) |
ctypes array |
bool |
c_bool |
bool |
1.6 Calling System Libraries with ctypes
You don't have to write your own C code to use ctypes. The C libraries that come with your operating system work too. Here we call sqrt and pow from the C math library.
import ctypes
import ctypes.util
# Find and load the C math library
libm_path = ctypes.util.find_library("m")
print(f"Math library path: {libm_path}")
if libm_path:
libm = ctypes.CDLL(libm_path)
else:
# On some systems, math functions are in libc
libm = ctypes.CDLL(None)
# Call the C sqrt function
libm.sqrt.argtypes = [ctypes.c_double]
libm.sqrt.restype = ctypes.c_double
print(f"C sqrt(144.0) = {libm.sqrt(144.0)}")
# Call the C pow function
libm.pow.argtypes = [ctypes.c_double, ctypes.c_double]
libm.pow.restype = ctypes.c_double
print(f"C pow(2, 10) = {libm.pow(2.0, 10.0)}")
Math library path: /usr/lib/libm.dylib
C sqrt(144.0) = 12.0
C pow(2, 10) = 1024.0
ctypes.util.find_library("m") searches the system for the math library (libm). On some systems the math functions live inside the main C library instead, so we fall back to ctypes.CDLL(None), which means "the C library this Python process is already using".
Try It: Call strlen with ctypes
Load the C standard library and call strlen to get the length of b"Hello, World!".
Hint: strlen takes a const char* and returns a size_t. On macOS and Linux, ctypes.CDLL(None) gives you the C library; on Windows use ctypes.cdll.msvcrt.
# Your code here:
# libc = ctypes.CDLL(None) # loads the default C library
# libc.strlen.argtypes = [???]
# libc.strlen.restype = ???
# result = libc.strlen(b"Hello, World!")
# print(f"strlen = {result}")
Show solution
libc = ctypes.CDLL(None) # Windows: ctypes.cdll.msvcrt
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
result = libc.strlen(b"Hello, World!")
print(f"strlen = {result}") # strlen = 13
Quick recap
ctypesis in the standard library and calls functions in an already-compiled shared library.- Always set
argtypesandrestype; the default assumesinteverywhere and can silently give wrong results. - Arrays are made with
ctypes.c_double * n; C can modify them in place. - C strings are
bytesin Python: encode before calling, decode the result.
2. cffi: C Foreign Function Interface
cffi does the same job as ctypes, but you describe the C functions using real C declarations instead of ctypes objects. It has two modes:
- ABI mode: load an existing shared library at runtime, just like
ctypes. - API mode: give
cffisome C source code; it compiles it into a Python module for you.
Why people often prefer cffi over ctypes:
- You can copy declarations straight from a C header file.
- Error messages are clearer.
- API mode lets the C compiler check your types, which catches many mistakes before they turn into crashes.
- It's the recommended way to call C from PyPy.
# Install cffi if needed
%pip install cffi -q
We use %pip rather than !pip so the package is installed into the same Python environment the notebook kernel is running in.
2.1 ABI Mode: Loading Existing Libraries
Here we load the same mylib we compiled for ctypes. Notice there is no argtypes/restype setup: the cdef declarations already contain all the type information.
from cffi import FFI
ffi = FFI()
# Declare the C functions we want to call
# This looks like actual C header declarations!
ffi.cdef("""
int add(int a, int b);
double multiply(double a, double b);
long long factorial(int n);
void square_array(double* arr, int size);
const char* greet(const char* name);
""")
# Load our compiled library
lib = ffi.dlopen(os.path.abspath(lib_name))
# Call functions -- much cleaner than ctypes!
print(f"add(15, 27) = {lib.add(15, 27)}")
print(f"multiply(2.5, 4.0) = {lib.multiply(2.5, 4.0)}")
print(f"factorial(12) = {lib.factorial(12)}")
add(15, 27) = 42
multiply(2.5, 4.0) = 10.0
factorial(12) = 479001600
# Working with arrays in cffi
arr = ffi.new("double[5]", [1.0, 2.0, 3.0, 4.0, 5.0])
print(f"Before: {[arr[i] for i in range(5)]}")
lib.square_array(arr, 5)
print(f"After: {[arr[i] for i in range(5)]}")
Before: [1.0, 2.0, 3.0, 4.0, 5.0]
After: [1.0, 4.0, 9.0, 16.0, 25.0]
ffi.new("double[5]", [...]) allocates a C array and fills it. cffi frees it automatically when arr is no longer used.
When a C function returns char*, cffi gives you a pointer object. ffi.string() copies the characters into a Python bytes object:
# Working with strings in cffi
result = lib.greet(b"Student")
print(ffi.string(result).decode("utf-8"))
Hello, Student! Welcome from C.
2.2 API Mode: Compile C Code Inline
In API mode, cffi writes a small C wrapper around your code, runs the compiler and produces a real extension module (here called _math_helpers). The cdef part says what Python can see; set_source holds the actual C code. The compiler output below is long, and that's normal.
from cffi import FFI
ffi_builder = FFI()
# Define the interface (what Python sees)
ffi_builder.cdef("""
int fibonacci(int n);
double distance(double x1, double y1, double x2, double y2);
""")
# Provide the actual C implementation
ffi_builder.set_source("_math_helpers", """
#include <math.h>
int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
double distance(double x1, double y1, double x2, double y2) {
return sqrt((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1));
}
""",
libraries=["m"] # link against libm for sqrt
)
# Compile the extension module
ffi_builder.compile(verbose=True)
generating ./_math_helpers.c
the current directory is '/path/to/prg400'
'/path/to/prg400/_math_helpers.cpython-312-darwin.so'
The build produced a file like _math_helpers.cpython-312-darwin.so in the current folder, so we can import it like any other module. It gives us its own ffi and lib objects.
Heads up: this replaces the
ffiandlibnames from the ABI-mode example above. That's why later cells reloadmylibunder a new name.
# Import and use the compiled module (it was built in the current folder)
from _math_helpers import ffi, lib
print(f"fibonacci(10) = {lib.fibonacci(10)}")
print(f"fibonacci(30) = {lib.fibonacci(30)}")
print(f"distance(0,0 -> 3,4) = {lib.distance(0, 0, 3, 4)}")
print(f"distance(1,2 -> 4,6) = {lib.distance(1, 2, 4, 6):.4f}")
fibonacci(10) = 55
fibonacci(30) = 832040
distance(0,0 -> 3,4) = 5.0
distance(1,2 -> 4,6) = 5.0000
ctypes vs cffi Comparison
| Feature | ctypes |
cffi |
|---|---|---|
| Part of stdlib | Yes | No (pip install) |
| Type declarations | Python objects (c_int, c_double) |
C syntax (int, double) |
| Compilation support | No (ABI only) | Yes (API mode) |
| Error messages | Cryptic (segfaults) | Better error reporting |
| PyPy support | Limited | Excellent |
| Learning curve | Lower | Slightly higher |
Quick recap
cffidescribes C functions with ordinary C declarations inffi.cdef().- ABI mode (
ffi.dlopen) loads an existing library, likectypes. - API mode (
set_source+compile) builds a real extension module from C source. - Use
ffi.new()to allocate C data andffi.string()to read C strings.
3. Python C API: Writing Extension Modules
The Python C API is the C interface CPython itself is built on. With it you write C functions that take and return real Python objects. NumPy's core and many of the standard library modules (like math) are written this way.
Use it when:
- you need full control and the best possible performance
- you're building a library that needs tight integration with Python's internals
The downsides:
- It's the most work: lots of boilerplate for every function.
- You manage memory yourself with reference counts (
Py_INCREF/Py_DECREF). - Mistakes cause memory leaks or segfaults.
- It only works with CPython (not PyPy and friends).
In practice you'll usually reach for pybind11 or Cython instead. We still look at the C API because every other tool generates this kind of code for you behind the scenes.
3.1 Anatomy of a C Extension Module
A C extension module has three parts:
- Function implementations: the C functions that do the work
- Method table: maps Python function names to those C functions
- Module definition: describes the module to Python, plus an init function named
PyInit_<modulename>
Read through the comments in the next cell; they walk through each part.
%%writefile mymodule.c
#include <Python.h>
// ========================================
// Part 1: Function Implementations
// ========================================
// Every C function that will be callable from Python has the signature:
// static PyObject* func_name(PyObject* self, PyObject* args)
static PyObject* mymodule_add(PyObject* self, PyObject* args) {
int a, b;
// Parse Python arguments: "ii" means two integers
if (!PyArg_ParseTuple(args, "ii", &a, &b)) {
return NULL; // Raise TypeError on parse failure
}
// Return a Python int object
return PyLong_FromLong(a + b);
}
static PyObject* mymodule_fibonacci(PyObject* self, PyObject* args) {
int n;
if (!PyArg_ParseTuple(args, "i", &n)) {
return NULL;
}
if (n < 0) {
PyErr_SetString(PyExc_ValueError, "n must be non-negative");
return NULL;
}
long long a = 0, b = 1;
for (int i = 0; i < n; i++) {
long long temp = a + b;
a = b;
b = temp;
}
return PyLong_FromLongLong(a);
}
static PyObject* mymodule_sum_list(PyObject* self, PyObject* args) {
PyObject* list_obj;
// "O" means a single Python object
if (!PyArg_ParseTuple(args, "O", &list_obj)) {
return NULL;
}
// Verify it's actually a list
if (!PyList_Check(list_obj)) {
PyErr_SetString(PyExc_TypeError, "Expected a list");
return NULL;
}
double total = 0.0;
Py_ssize_t size = PyList_Size(list_obj);
for (Py_ssize_t i = 0; i < size; i++) {
PyObject* item = PyList_GetItem(list_obj, i); // Borrowed reference
if (PyLong_Check(item)) {
total += PyLong_AsDouble(item);
} else if (PyFloat_Check(item)) {
total += PyFloat_AsDouble(item);
} else {
PyErr_SetString(PyExc_TypeError, "List must contain only numbers");
return NULL;
}
}
return PyFloat_FromDouble(total);
}
// ========================================
// Part 2: Method Table
// ========================================
static PyMethodDef MyModuleMethods[] = {
{"add", mymodule_add, METH_VARARGS, "Add two integers."},
{"fibonacci", mymodule_fibonacci, METH_VARARGS, "Compute nth Fibonacci number."},
{"sum_list", mymodule_sum_list, METH_VARARGS, "Sum a list of numbers."},
{NULL, NULL, 0, NULL} // Sentinel: marks end of table
};
// ========================================
// Part 3: Module Definition
// ========================================
static struct PyModuleDef mymodule = {
PyModuleDef_HEAD_INIT,
"mymodule", // Module name
"A sample C extension module for demonstration.", // Docstring
-1, // -1 means module keeps state in global variables
MyModuleMethods // Method table
};
// Module initialization function
// MUST be named PyInit_<modulename>
PyMODINIT_FUNC PyInit_mymodule(void) {
return PyModule_Create(&mymodule);
}
Writing mymodule.c
To compile an extension module we need the right compiler flags and the Python header files. setuptools knows all of this, so we write a tiny setup.py and let it do the work.
%%writefile setup.py
from setuptools import setup, Extension
module = Extension(
'mymodule',
sources=['mymodule.c'],
)
setup(
name='mymodule',
version='1.0',
description='A sample C extension module',
ext_modules=[module],
)
Writing setup.py
import sys
# Build the extension module in the current folder.
# {sys.executable} makes sure we use the same Python as this notebook.
!{sys.executable} setup.py build_ext --inplace 2>&1 | tail -5
running build_ext
building 'mymodule' extension
clang -fno-strict-overflow -Wsign-compare -Wunreachable-code -fno-common -dynamic -DNDEBUG -g -O3 -Wall -I/path/to/prg400/venv/include -I/opt/homebrew/opt/[email protected]/Frameworks/Python.framework/Versions/3.12/include/python3.12 -c mymodule.c -o build/temp.macosx-26.0-arm64-cpython-312/mymodule.o
clang -bundle -undefined dynamic_lookup build/temp.macosx-26.0-arm64-cpython-312/mymodule.o -o build/lib.macosx-26.0-arm64-cpython-312/mymodule.cpython-312-darwin.so
copying build/lib.macosx-26.0-arm64-cpython-312/mymodule.cpython-312-darwin.so ->
build_ext --inplace puts the compiled module (e.g. mymodule.cpython-312-darwin.so) right next to the notebook, so a normal import finds it.
# Use our C extension module!
import mymodule
print(f"mymodule.add(100, 200) = {mymodule.add(100, 200)}")
print(f"mymodule.fibonacci(10) = {mymodule.fibonacci(10)}")
print(f"mymodule.fibonacci(50) = {mymodule.fibonacci(50)}")
print(f"mymodule.sum_list([1, 2.5, 3, 4.5]) = {mymodule.sum_list([1, 2.5, 3, 4.5])}")
mymodule.add(100, 200) = 300
mymodule.fibonacci(10) = 55
mymodule.fibonacci(50) = 12586269025
mymodule.sum_list([1, 2.5, 3, 4.5]) = 11.0
Note that fibonacci(50) is 12,586,269,025, bigger than a 32-bit int can hold. It works because the C code uses long long and returns it with PyLong_FromLongLong.
Errors raised in C with PyErr_SetString arrive in Python as normal exceptions:
# Error handling works too!
try:
mymodule.fibonacci(-1)
except ValueError as e:
print(f"Caught error: {e}")
try:
mymodule.sum_list("not a list")
except TypeError as e:
print(f"Caught error: {e}")
try:
mymodule.sum_list([1, 2, "three"])
except TypeError as e:
print(f"Caught error: {e}")
Caught error: n must be non-negative
Caught error: Expected a list
Caught error: List must contain only numbers
3.2 PyArg_ParseTuple Format Codes
PyArg_ParseTuple(args, "ii", &a, &b) reads the Python arguments into C variables. The string says what types to expect:
| Code | C Type | Python Type |
|---|---|---|
i |
int |
int |
l |
long |
int |
L |
long long |
int |
f |
float |
float |
d |
double |
float |
s |
const char* |
str |
O |
PyObject* |
any object |
O! |
PyObject* (with type check) |
specific type |
| |
(none) | marks the start of optional arguments |
3.3 Reference Counting: The Critical Concept
Every Python object keeps a count of how many places refer to it. When the count drops to zero, the object is freed. In Python this happens for you; in the C API you keep the count correct.
// You got a NEW reference (you own it) -- must DECREF when done
PyObject* result = PyLong_FromLong(42);
// ... use result ...
Py_DECREF(result); // Release your reference
// You got a BORROWED reference (someone else owns it) -- do NOT DECREF
PyObject* item = PyList_GetItem(list, 0); // Borrowed!
// ... use item but do NOT Py_DECREF(item) ...
The rules:
- Functions that create objects give you a new reference. You must
Py_DECREFit when you're done (or return it). - Functions that get items from containers usually give you a borrowed reference. Don't
Py_DECREFit. - When your function returns an object, ownership passes to the caller.
- If you want to keep a borrowed reference for later, call
Py_INCREFon it first.
Get this wrong in one direction and you leak memory; get it wrong in the other and Python frees an object you're still using.
Quick recap
- A C extension module has three parts: functions, a method table and a module definition with
PyInit_<name>. PyArg_ParseTupleconverts Python arguments to C;PyLong_From.../PyFloat_From...convert results back.- Return
NULLafterPyErr_SetStringto raise a Python exception. - New references must be released with
Py_DECREF; borrowed references must not. - Build with
setuptools(setup.py build_ext --inplace).
4. pybind11: Modern C++ Bindings
pybind11 is a header-only C++ library for exposing C++ code to Python. "Header-only" means there's nothing to link: you #include it and compile. Compared with the raw C API, it removes almost all the boilerplate.
What you get:
- Short, readable binding code
- Automatic conversions between STL containers and Python types (
std::vector↔list,std::string↔str, ...) - Support for classes, inheritance, overloading and lambdas
- C++ exceptions turned into Python exceptions
It's currently the most popular way to bind C++ code (its successor nanobind works the same way).
# Install pybind11
%pip install pybind11 -q
4.1 A Simple pybind11 Module
All the binding code lives in the PYBIND11_MODULE block at the bottom. Everything above it is ordinary C++ that knows nothing about Python.
%%writefile mathtools.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // For automatic STL <-> Python conversions
#include <string>
#include <vector>
#include <numeric>
#include <cmath>
#include <algorithm>
namespace py = pybind11;
// ---- Simple Functions ----
int add(int a, int b) {
return a + b;
}
double hypotenuse(double a, double b) {
return std::sqrt(a * a + b * b);
}
std::string repeat_string(const std::string& s, int n) {
std::string result;
for (int i = 0; i < n; i++) {
result += s;
}
return result;
}
// ---- Working with STL Containers ----
// pybind11 automatically converts std::vector <-> Python list!
std::vector<int> range_vec(int start, int end) {
std::vector<int> result;
for (int i = start; i < end; i++) {
result.push_back(i);
}
return result;
}
double dot_product(const std::vector<double>& a, const std::vector<double>& b) {
if (a.size() != b.size()) {
throw std::runtime_error("Vectors must have the same length");
}
return std::inner_product(a.begin(), a.end(), b.begin(), 0.0);
}
// ---- A C++ Class Exposed to Python ----
class Vector2D {
public:
double x, y;
Vector2D(double x = 0, double y = 0) : x(x), y(y) {}
double magnitude() const {
return std::sqrt(x * x + y * y);
}
Vector2D normalized() const {
double mag = magnitude();
if (mag == 0) throw std::runtime_error("Cannot normalize zero vector");
return Vector2D(x / mag, y / mag);
}
Vector2D operator+(const Vector2D& other) const {
return Vector2D(x + other.x, y + other.y);
}
double dot(const Vector2D& other) const {
return x * other.x + y * other.y;
}
std::string repr() const {
return "Vector2D(" + std::to_string(x) + ", " + std::to_string(y) + ")";
}
};
// ---- Module Definition ----
PYBIND11_MODULE(mathtools, m) {
m.doc() = "A C++ math utilities module built with pybind11";
// Bind simple functions
m.def("add", &add, "Add two integers",
py::arg("a"), py::arg("b")); // Named arguments!
m.def("hypotenuse", &hypotenuse, "Compute hypotenuse",
py::arg("a"), py::arg("b"));
m.def("repeat_string", &repeat_string, "Repeat a string n times",
py::arg("s"), py::arg("n") = 2); // Default argument!
m.def("range_vec", &range_vec, "Create a vector of integers",
py::arg("start"), py::arg("end"));
m.def("dot_product", &dot_product, "Compute dot product of two vectors",
py::arg("a"), py::arg("b"));
// Bind a C++ class
py::class_<Vector2D>(m, "Vector2D")
.def(py::init<double, double>(), // Constructor
py::arg("x") = 0, py::arg("y") = 0)
.def_readwrite("x", &Vector2D::x) // Public attributes
.def_readwrite("y", &Vector2D::y)
.def("magnitude", &Vector2D::magnitude)
.def("normalized", &Vector2D::normalized)
.def("dot", &Vector2D::dot, py::arg("other"))
.def("__add__", &Vector2D::operator+) // Python operator overloading!
.def("__repr__", &Vector2D::repr);
}
Writing mathtools.cpp
4.2 Compiling the Module
We compile with the C++ compiler directly. It needs two include folders (Python's headers and pybind11's headers), and the output file must end with Python's extension suffix (e.g. .cpython-312-darwin.so) so import can find it.
On macOS we also pass -undefined dynamic_lookup, which says "the Python functions you can't find yet will be provided by the Python process that loads this module". Linux doesn't need it.
import platform
import sys
import sysconfig
import pybind11
include_flags = f"-I{sysconfig.get_paths()['include']} -I{pybind11.get_include()}"
ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
mac_flags = "-undefined dynamic_lookup" if platform.system() == "Darwin" else ""
print(f"Include flags: {include_flags}")
print(f"Extension suffix: {ext_suffix}")
# Compile
!c++ -O2 -shared -std=c++17 -fPIC {mac_flags} {include_flags} mathtools.cpp -o mathtools{ext_suffix}
Include flags: -I/opt/homebrew/opt/[email protected]/Frameworks/Python.framework/Versions/3.12/include/python3.12 -I/path/to/prg400/venv/lib/python3.12/site-packages/pybind11/include
Extension suffix: .cpython-312-darwin.so
No output after the two prints means the compile succeeded. For a real project you'd let setuptools or scikit-build run this command for you, but it's worth seeing it once by hand.
Now the module behaves like any Python module, including keyword and default arguments:
import mathtools
# Simple functions
print(f"add(a=5, b=3) = {mathtools.add(a=5, b=3)}")
print(f"hypotenuse(3, 4) = {mathtools.hypotenuse(3, 4)}")
print(f"repeat_string('Ha', 3) = {mathtools.repeat_string('Ha', 3)}")
print(f"repeat_string('Hi') = {mathtools.repeat_string('Hi')} (default n=2)")
add(a=5, b=3) = 8
hypotenuse(3, 4) = 5.0
repeat_string('Ha', 3) = HaHaHa
repeat_string('Hi') = HiHi (default n=2)
# STL containers converted automatically!
nums = mathtools.range_vec(1, 6)
print(f"range_vec(1, 6) = {nums}")
print(f"Type: {type(nums)}")
# Pass Python lists to C++ std::vector
dp = mathtools.dot_product([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
print(f"dot_product([1,2,3], [4,5,6]) = {dp}")
range_vec(1, 6) = [1, 2, 3, 4, 5]
Type: <class 'list'>
dot_product([1,2,3], [4,5,6]) = 32.0
range_vec returned a C++ std::vector<int>, and pybind11 turned it into a normal Python list. Going the other way, our Python lists became std::vector<double> for dot_product.
C++ classes work too. Because we bound operator+ as __add__ and repr() as __repr__, Vector2D objects support + and print nicely:
# Using the C++ class from Python
v1 = mathtools.Vector2D(3, 4)
v2 = mathtools.Vector2D(1, 2)
print(f"v1 = {v1}")
print(f"v2 = {v2}")
print(f"v1.magnitude() = {v1.magnitude()}")
print(f"v1.normalized() = {v1.normalized()}")
print(f"v1 + v2 = {v1 + v2}")
print(f"v1.dot(v2) = {v1.dot(v2)}")
# Access attributes
print(f"v1.x = {v1.x}, v1.y = {v1.y}")
v1.x = 10
print(f"After v1.x = 10: {v1}")
v1 = Vector2D(3.000000, 4.000000)
v2 = Vector2D(1.000000, 2.000000)
v1.magnitude() = 5.0
v1.normalized() = Vector2D(0.600000, 0.800000)
v1 + v2 = Vector2D(4.000000, 6.000000)
v1.dot(v2) = 11.0
v1.x = 3.0, v1.y = 4.0
After v1.x = 10: Vector2D(10.000000, 4.000000)
# C++ exceptions become Python exceptions!
try:
mathtools.dot_product([1, 2], [1, 2, 3])
except RuntimeError as e:
print(f"Caught: {e}")
try:
zero = mathtools.Vector2D(0, 0)
zero.normalized()
except RuntimeError as e:
print(f"Caught: {e}")
Caught: Vectors must have the same length
Caught: Cannot normalize zero vector
A std::runtime_error thrown in C++ arrives in Python as a RuntimeError, with the same message.
Quick recap
pybind11is a header-only C++ library; all binding code lives inPYBIND11_MODULE.m.def(...)binds functions;py::arggives keyword and default arguments.py::class_<T>binds classes, including attributes, methods and operators.- STL containers and C++ exceptions are converted to Python types automatically.
5. Cython: Python-like Syntax, C-like Speed
Cython is a language that is almost Python, plus optional C type declarations (cdef int i). Cython translates your code into C and compiles it into an extension module.
Why use it:
- It's the easiest way to speed up existing Python code: start with plain Python, then add types to the slow parts.
- It can also call C and C++ libraries directly.
- Big projects such as scikit-learn, pandas and lxml use it.
In Jupyter, the %%cython cell magic compiles a cell for you, so you don't need a setup.py.
%pip install cython -q
# Enable the %%cython magic
%load_ext Cython
5.1 Basic Cython: Adding Type Declarations
Let's start with a pure Python function that finds primes, and time it.
# Pure Python version -- for comparison
def python_primes(n):
"""Find all primes up to n using trial division."""
primes = []
for candidate in range(2, n + 1):
is_prime = True
for divisor in range(2, int(candidate ** 0.5) + 1):
if candidate % divisor == 0:
is_prime = False
break
if is_prime:
primes.append(candidate)
return primes
%timeit python_primes(10000)
2.33 ms ± 18.3 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Now the Cython version. The algorithm is identical; we only declare the C types of the variables.
We take the square root with C's sqrt and cast it to an int with <int>. Watch out: writing candidate ** 0.5 with a C int here does not behave like Python, and in an earlier version of this notebook it quietly reported 25 and 49 as primes.
%%cython
# Cython version: almost the same code, but with type declarations!
from libc.math cimport sqrt
def cython_primes(int n):
"""Find all primes up to n using trial division (Cython)."""
cdef list primes = []
cdef int candidate, divisor
cdef bint is_prime
for candidate in range(2, n + 1):
is_prime = True
for divisor in range(2, <int>sqrt(candidate) + 1):
if candidate % divisor == 0:
is_prime = False
break
if is_prime:
primes.append(candidate)
return primes
# Compare performance!
print("Pure Python:")
%timeit python_primes(10000)
print("\nCython:")
%timeit cython_primes(10000)
# Always check that the fast version gives the same answer
assert python_primes(10000) == cython_primes(10000)
print(cython_primes(100))
print(f"\nResults match! Found {len(cython_primes(100))} primes up to 100")
Pure Python:
2.34 ms ± 16.8 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Cython:
76 μs ± 683 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
Results match! Found 25 primes up to 100
What just happened? The typed loop variables let Cython turn the inner loop into plain C arithmetic with no Python objects involved, which is where the speedup comes from. The assert matters just as much as the timing: a fast function that gives wrong answers is useless. Your exact timings will differ from your neighbour's.
5.2 Cython with C Functions
cimport (note the c) imports C-level declarations. Here we use C's sqrt, sin, cos and M_PI straight from libc.math.
%%cython
# Cython can call C standard library functions directly!
from libc.math cimport sqrt, sin, cos, M_PI
def fast_distance(double x1, double y1, double x2, double y2):
"""Compute Euclidean distance using C's sqrt."""
cdef double dx = x2 - x1
cdef double dy = y2 - y1
return sqrt(dx * dx + dy * dy)
def circle_points(int n):
"""Generate n points on a unit circle."""
cdef list points = []
cdef double angle
cdef int i
for i in range(n):
angle = 2.0 * M_PI * i / n
points.append((cos(angle), sin(angle)))
return points
print(f"distance(0,0 -> 3,4) = {fast_distance(0, 0, 3, 4)}")
points = circle_points(8)
print(f"\n8 points on unit circle:")
for i, (x, y) in enumerate(points):
print(f" Point {i}: ({x:7.4f}, {y:7.4f})")
distance(0,0 -> 3,4) = 5.0
8 points on unit circle:
Point 0: ( 1.0000, 0.0000)
Point 1: ( 0.7071, 0.7071)
Point 2: ( 0.0000, 1.0000)
Point 3: (-0.7071, 0.7071)
Point 4: (-1.0000, 0.0000)
Point 5: (-0.7071, -0.7071)
Point 6: (-0.0000, -1.0000)
Point 7: ( 0.7071, -0.7071)
5.3 Wrapping Existing C Code with Cython
To call a C function Cython doesn't know about yet, you declare it in a cdef extern from block. It works like a header file: you tell Cython the function names and their types.
%%cython
# Cython can wrap external C functions
# First, declare the C interface (like a header file)
cdef extern from "string.h":
size_t strlen(const char* s)
int strcmp(const char* s1, const char* s2)
def c_string_length(bytes s):
"""Get string length using C's strlen."""
return strlen(s)
def c_string_compare(bytes s1, bytes s2):
"""Compare strings using C's strcmp.
Returns: negative if s1 < s2, 0 if equal, positive if s1 > s2"""
cdef int result = strcmp(s1, s2)
if result < 0:
return f"{s1} < {s2}"
elif result == 0:
return f"{s1} == {s2}"
else:
return f"{s1} > {s2}"
print(f"strlen('Hello') = {c_string_length(b'Hello')}")
print(f"strlen('Python Bindings') = {c_string_length(b'Python Bindings')}")
print(f"\nstrcmp: {c_string_compare(b'apple', b'banana')}")
print(f"strcmp: {c_string_compare(b'hello', b'hello')}")
print(f"strcmp: {c_string_compare(b'zebra', b'aardvark')}")
strlen('Hello') = 5
strlen('Python Bindings') = 15
strcmp: b'apple' < b'banana'
strcmp: b'hello' == b'hello'
strcmp: b'zebra' > b'aardvark'
strcmp only tells us the order of two strings: negative, zero or positive. The Python wrapper turns that number into a readable message.
Quick recap
- Cython is Python plus optional C type declarations (
cdef int x), compiled to C. - In Jupyter,
%load_ext Cythonand then%%cythoncompiles a cell. - Typed loops run as plain C and can be many times faster than Python.
cimportandcdef extern fromgive direct access to C functions.- Always check that the optimised version gives the same results as the original.
6. Performance Comparison
Let's benchmark several approaches on the same task: the sum of squares of 100,000 numbers.
First, the Cython version:
%%cython
def cython_sum_squares(list data):
cdef double total = 0.0
cdef double x
cdef int i
cdef int n = len(data)
for i in range(n):
x = data[i]
total += x * x
return total
import time
# Prepare test data
test_data = list(range(1, 100001)) # 100,000 numbers
# --- Pure Python ---
def python_sum_squares(data):
total = 0.0
for x in data:
total += x * x
return total
# --- ctypes version ---
# Reload mylib under a new name (`lib` now points at the cffi module from section 2)
ctypes_lib = ctypes.CDLL(os.path.abspath(lib_name))
ctypes_lib.square_array.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int]
ctypes_lib.square_array.restype = None
def ctypes_sum_squares(data):
n = len(data)
ArrType = ctypes.c_double * n
arr = ArrType(*data)
ctypes_lib.square_array(arr, n)
return sum(arr)
# --- Benchmark ---
approaches = [
("Pure Python", python_sum_squares),
("Python built-in sum", lambda d: sum(x*x for x in d)),
("ctypes (C)", ctypes_sum_squares),
("Cython", cython_sum_squares),
]
print(f"{'Approach':<25} {'Time (ms)':>12} {'Speedup':>10}")
print("-" * 50)
baseline = None
for name, func in approaches:
# Warm up
func(test_data)
# Time it
start = time.perf_counter()
for _ in range(100):
result = func(test_data)
elapsed = (time.perf_counter() - start) / 100 * 1000 # ms
if baseline is None:
baseline = elapsed
speedup = baseline / elapsed
print(f"{name:<25} {elapsed:>10.3f}ms {speedup:>9.1f}x")
Approach Time (ms) Speedup
--------------------------------------------------
Pure Python 2.096ms 1.0x
Python built-in sum 2.112ms 1.0x
ctypes (C) 7.709ms 0.3x
Cython 0.527ms 4.0x
What just happened? Two results usually surprise students:
ctypesis the slowest, even though the squaring happens in C. Converting 100,000 Python numbers into a C array (and summing them back in Python) costs far more than the C work itself. Crossing the Python/C boundary has a price, so bindings pay off when C does a lot of work per call, not when you shuttle data back and forth.- Cython wins because the whole loop, including reading the list, runs as compiled code.
Your numbers will be different on your machine, but the ranking is usually the same.
Quick recap
- Calling C is not automatically faster: converting data between Python and C costs time.
- Bindings pay off when each call does a lot of work in C.
- Measure before and after optimising, and on realistic data.
7. Structures and Complex Data Types
Real C code often groups data in structs. Here we use ctypes to pass structs to C.
7.1 Structs with ctypes
First, a C library that works with two structs: a 3D point and a student record.
%%writefile structs.c
#include <stdio.h>
#include <math.h>
#include <string.h>
typedef struct {
double x;
double y;
double z;
} Point3D;
double point_distance(Point3D* a, Point3D* b) {
double dx = b->x - a->x;
double dy = b->y - a->y;
double dz = b->z - a->z;
return sqrt(dx*dx + dy*dy + dz*dz);
}
void point_midpoint(Point3D* a, Point3D* b, Point3D* result) {
result->x = (a->x + b->x) / 2.0;
result->y = (a->y + b->y) / 2.0;
result->z = (a->z + b->z) / 2.0;
}
typedef struct {
char name[64];
int age;
double gpa;
} Student;
void print_student(Student* s) {
printf("Student: %s, Age: %d, GPA: %.2f\n", s->name, s->age, s->gpa);
}
Writing structs.c
import platform
# Compile (Linux needs -lm to link sqrt from the math library)
if platform.system() == "Darwin":
!gcc -shared -o structs.dylib -fPIC structs.c
struct_lib_name = "structs.dylib"
elif platform.system() == "Linux":
!gcc -shared -o structs.so -fPIC structs.c -lm
struct_lib_name = "structs.so"
else:
!gcc -shared -o structs.dll structs.c
struct_lib_name = "structs.dll"
print(f"Compiled: {struct_lib_name}")
Compiled: structs.dylib
On the Python side, a struct is a class that inherits from ctypes.Structure and lists its fields in _fields_. The fields must be in the same order with the same types as the C struct, because ctypes uses them to lay out the bytes in memory.
ctypes.byref(p1) passes the address of p1, which is what a Point3D* parameter expects.
import ctypes
import os
# Define the C struct as a Python class
class Point3D(ctypes.Structure):
_fields_ = [
("x", ctypes.c_double),
("y", ctypes.c_double),
("z", ctypes.c_double),
]
def __repr__(self):
return f"Point3D({self.x}, {self.y}, {self.z})"
class Student(ctypes.Structure):
_fields_ = [
("name", ctypes.c_char * 64),
("age", ctypes.c_int),
("gpa", ctypes.c_double),
]
# Load the library
slib = ctypes.CDLL(os.path.abspath(struct_lib_name))
# Set up function signatures
slib.point_distance.argtypes = [ctypes.POINTER(Point3D), ctypes.POINTER(Point3D)]
slib.point_distance.restype = ctypes.c_double
slib.point_midpoint.argtypes = [
ctypes.POINTER(Point3D), ctypes.POINTER(Point3D), ctypes.POINTER(Point3D)
]
slib.point_midpoint.restype = None
# Create struct instances and use them
p1 = Point3D(1.0, 2.0, 3.0)
p2 = Point3D(4.0, 6.0, 3.0)
dist = slib.point_distance(ctypes.byref(p1), ctypes.byref(p2))
print(f"Distance between {p1} and {p2} = {dist:.4f}")
mid = Point3D()
slib.point_midpoint(ctypes.byref(p1), ctypes.byref(p2), ctypes.byref(mid))
print(f"Midpoint = {mid}")
Distance between Point3D(1.0, 2.0, 3.0) and Point3D(4.0, 6.0, 3.0) = 5.0000
Midpoint = Point3D(2.5, 4.0, 3.0)
point_midpoint doesn't return anything. It writes its answer into the mid struct we passed by reference, the same "output parameter" pattern as square_array.
A fixed-size char name[64] becomes ctypes.c_char * 64, and we fill it with bytes:
# Using a struct with strings
slib.print_student.argtypes = [ctypes.POINTER(Student)]
slib.print_student.restype = None
student = Student()
student.name = b"Alice Johnson" # C strings are bytes
student.age = 21
student.gpa = 3.85
slib.print_student(ctypes.byref(student))
# The printf above writes to the C-level stdout; print from Python as well
print(f"From Python: {student.name.decode()}, {student.age}, {student.gpa}")
From Python: Alice Johnson, 21, 3.85
The
printfoutput comes from C, not from Python's
Quick recap
- Mirror a C struct with a
ctypes.Structuresubclass and a_fields_list in the same order as C. - Use
ctypes.byref(obj)to pass a pointer to a struct. - Fixed-size C strings (
char name[64]) becomectypes.c_char * 64and takebytes.
8. Callbacks: Passing Python Functions to C
Sometimes C needs to call back into Python: think of a sort that takes a comparison function, an event handler, or a numerical routine that needs "the function to integrate". In C this is done with a function pointer.
%%writefile callbacks.c
#include <stdio.h>
// Type for a callback function: takes a double, returns a double
typedef double (*MathFunc)(double);
// Apply a function to each element of an array
void apply_func(double* arr, int size, MathFunc func) {
for (int i = 0; i < size; i++) {
arr[i] = func(arr[i]);
}
}
// Numerical integration using the trapezoidal rule
double integrate(MathFunc f, double a, double b, int n) {
double h = (b - a) / n;
double sum = 0.5 * (f(a) + f(b));
for (int i = 1; i < n; i++) {
sum += f(a + i * h);
}
return sum * h;
}
Writing callbacks.c
import platform
if platform.system() == "Darwin":
!gcc -shared -o callbacks.dylib -fPIC callbacks.c
cb_lib_name = "callbacks.dylib"
elif platform.system() == "Linux":
!gcc -shared -o callbacks.so -fPIC callbacks.c
cb_lib_name = "callbacks.so"
else:
!gcc -shared -o callbacks.dll callbacks.c
cb_lib_name = "callbacks.dll"
print(f"Compiled: {cb_lib_name}")
Compiled: callbacks.dylib
ctypes.CFUNCTYPE(return_type, arg_types...) creates a function-pointer type. Using it as a decorator wraps a Python function so C can call it like a normal C function.
import ctypes
import math
# Define the callback type: double (*)(double)
MATHFUNC = ctypes.CFUNCTYPE(ctypes.c_double, ctypes.c_double)
# Load the library
cblib = ctypes.CDLL(os.path.abspath(cb_lib_name))
# Set up apply_func
cblib.apply_func.argtypes = [
ctypes.POINTER(ctypes.c_double), ctypes.c_int, MATHFUNC
]
cblib.apply_func.restype = None
# Create a Python callback and wrap it
@MATHFUNC
def square(x):
return x * x
@MATHFUNC
def cube(x):
return x * x * x
# Apply the callback
data = (ctypes.c_double * 5)(1, 2, 3, 4, 5)
print(f"Original: {list(data)}")
cblib.apply_func(data, 5, square)
print(f"Squared: {list(data)}")
# Reset and try cube
data = (ctypes.c_double * 5)(1, 2, 3, 4, 5)
cblib.apply_func(data, 5, cube)
print(f"Cubed: {list(data)}")
Original: [1.0, 2.0, 3.0, 4.0, 5.0]
Squared: [1.0, 4.0, 9.0, 16.0, 25.0]
Cubed: [1.0, 8.0, 27.0, 64.0, 125.0]
# Numerical integration with callbacks
cblib.integrate.argtypes = [MATHFUNC, ctypes.c_double, ctypes.c_double, ctypes.c_int]
cblib.integrate.restype = ctypes.c_double
# Integrate sin(x) from 0 to pi => should be 2.0
@MATHFUNC
def sin_func(x):
return math.sin(x)
result = cblib.integrate(sin_func, 0, math.pi, 10000)
print(f"Integral of sin(x) from 0 to pi = {result:.6f} (expected: 2.0)")
# Integrate x^2 from 0 to 1 => should be 1/3 = 0.333...
result = cblib.integrate(square, 0, 1, 10000)
print(f"Integral of x^2 from 0 to 1 = {result:.6f} (expected: 0.333333)")
Integral of sin(x) from 0 to pi = 2.000000 (expected: 2.0)
Integral of x^2 from 0 to 1 = 0.333333 (expected: 0.333333)
What just happened? integrate called our Python sin_func 10,001 times from inside C. Each call crosses from C back into Python, so callbacks are convenient but not fast.
One more trap: keep a Python reference to every callback object (here the names square, cube, sin_func) for as long as C might call it. If Python garbage-collects it while C still holds the pointer, the program crashes.
Quick recap
- C uses function pointers for callbacks;
ctypes.CFUNCTYPEbuilds the matching type. - Decorate a Python function with that type to pass it to C.
- Every callback call crosses back into Python, so it's slow in tight loops.
- Keep a reference to callback objects while C may still use them.
9. SWIG: Simplified Wrapper and Interface Generator
SWIG reads your C/C++ header files and generates binding code for many languages: Python, Java, Ruby and more. We won't run it here (it's a separate tool to install), but you should recognise it when you meet it in older projects.
How SWIG Works
mylib.h (C/C++ header) + mylib.i (SWIG interface file)
|
v
swig -python
|
v
mylib_wrap.c (auto-generated) + mylib.py (Python wrapper)
|
v
gcc / compile
|
v
_mylib.so (shared library)
When to Use SWIG
- You have a large existing C/C++ codebase.
- You need bindings for several languages, not just Python.
- You want the wrappers generated with as little hand-written code as possible.
SWIG Interface File Example
// mylib.i -- SWIG interface file
%module mylib
%{
#include "mylib.h" // Include our header
%}
// Tell SWIG which functions to wrap
int add(int a, int b);
double multiply(double a, double b);
Then build with:
swig -python mylib.i
gcc -shared -fPIC -I/usr/include/python3.x mylib.c mylib_wrap.c -o _mylib.so
SWIG works, but the generated code is large and hard to debug. For new Python-only projects, pybind11 or Cython are usually a better choice.
Quick recap
- SWIG generates bindings from header files plus a small
.iinterface file. - Its strength is targeting many languages from one codebase.
- For new Python-only projects, prefer
pybind11or Cython.
10. Choosing the Right Approach
Decision Flowchart
Do you already have a compiled C/C++ library (.so/.dll)?
|
+-- YES --> Is it a simple API (few functions)?
| |
| +-- YES --> Use ctypes or cffi (ABI mode)
| +-- NO --> Use cffi (API mode) or pybind11
|
+-- NO --> Are you writing new performance-critical code?
|
+-- In C? --> Python C API or cffi (API mode)
+-- In C++? --> pybind11
+-- Prefer Python-like syntax? --> Cython
Summary Table
| Approach | Best For | Avoid When |
|---|---|---|
ctypes |
Quick prototyping, calling system libs | Complex APIs, C++ |
cffi |
Clean C bindings, PyPy support | C++ classes |
| Python C API | Maximum control, CPython internals | Rapid development |
pybind11 |
C++ code, classes, templates | Pure C projects |
| Cython | Speeding up Python code incrementally | When you can't install Cython |
| SWIG | Multi-language bindings, large codebases | Small projects |
Quick recap
- Existing C library with a few functions:
ctypesorcffiABI mode. - C++ code, especially classes:
pybind11. - Slow Python code you want to speed up gradually: Cython.
- The raw C API is for when you really need full control.
11. Best Practices and Common Pitfalls
Do's
- Always set
argtypesandrestypeinctypes. Wrong types cause wrong results or crashes. - Decide who allocates and who frees every piece of memory, and write it down.
- Test with small inputs before scaling up.
- Wrap calls in
try/exceptwhere the binding raises Python exceptions. (A real crash inside C can't be caught; it kills the kernel.) - Document the C types each function expects.
Don'ts
- Don't forget that C strings are
bytes(b"hello"), notstr. - Don't pass Python objects to C functions that expect C types.
- Don't ignore return values that signal errors.
- Don't assume a compiled library works on another OS or CPU architecture. Recompile it there.
- Don't call C through
ctypesinside a tight Python loop; the per-call overhead adds up.
Memory Safety
ctypes arrays check their index on the Python side. C does no such check, so the real danger is giving C the wrong size.
arr = (ctypes.c_int * 5)()
# Python-side indexing is bounds-checked by ctypes:
try:
arr[10] = 42
except IndexError as e:
print(f"ctypes caught it: {e}")
# C has no such check. This would make square_array write past the
# end of a 5-element buffer: undefined behaviour, maybe a crash.
# DON'T run it:
# ctypes_lib.square_array((ctypes.c_double * 5)(), 1000)
# SAFE: always pass the real length
small = (ctypes.c_double * 5)(1, 2, 3, 4, 5)
ctypes_lib.square_array(small, len(small))
print(list(small))
ctypes caught it: invalid index
[1.0, 4.0, 9.0, 16.0, 25.0]
Using len(small) instead of a hand-typed number means the size you pass to C can never drift away from the real size of the array.
Quick recap
- Set types explicitly and agree on who owns each piece of memory.
- C does no bounds checking: always pass the true length of a buffer.
- A crash in C kills the whole Python process;
try/exceptcan't save you. - Recompile native libraries for each OS and CPU architecture.
Exercises
Each exercise has an empty code cell for your answer. Try it yourself before opening the solution.
Exercise 1: Matrix Multiplication with ctypes
Write a C function that multiplies two 3x3 matrices, compile it, and call it from Python using ctypes.
- Write a C function
void mat_mult(double* A, double* B, double* C, int n)that storesA × BinC. Store each matrix as a flat array ofn*ndoubles in row order, so element(i, j)is at indexi*n + j. - Compile it into a shared library.
- Load it with
ctypesand call it. - Check the result against a matrix product computed in plain Python.
# Your code here:
Show solution
import ctypes, os, platform, subprocess
c_src = r"""
void mat_mult(double* A, double* B, double* C, int n) {
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) {
double s = 0.0;
for (int k = 0; k < n; k++)
s += A[i*n + k] * B[k*n + j];
C[i*n + j] = s;
}
}
"""
with open("matmul.c", "w") as f:
f.write(c_src)
ext = {"Darwin": "dylib", "Linux": "so"}.get(platform.system(), "dll")
subprocess.run(["gcc", "-shared", "-fPIC", "-o", f"matmul.{ext}", "matmul.c"], check=True)
mm = ctypes.CDLL(os.path.abspath(f"matmul.{ext}"))
Arr = ctypes.POINTER(ctypes.c_double)
mm.mat_mult.argtypes = [Arr, Arr, Arr, ctypes.c_int]
mm.mat_mult.restype = None
n = 3
A = [1, 2, 3, 4, 5, 6, 7, 8, 9]
B = [9, 8, 7, 6, 5, 4, 3, 2, 1]
a = (ctypes.c_double * 9)(*A)
b = (ctypes.c_double * 9)(*B)
c = (ctypes.c_double * 9)()
mm.mat_mult(a, b, c, n)
expected = [sum(A[i*n + k] * B[k*n + j] for k in range(n))
for i in range(n) for j in range(n)]
print(list(c)) # [30.0, 24.0, 18.0, 84.0, 69.0, 54.0, 138.0, 114.0, 90.0]
assert list(c) == expected
Exercise 2: String Processing with cffi
Use cffi in API mode to write a C function that:
- takes a string and a character
- returns how many times that character appears in the string
Use ffi.cdef() and ffi.set_source() to define and compile it, then call it from Python.
# Your code here:
Show solution
from cffi import FFI
builder = FFI()
builder.cdef("int count_char(const char* s, char c);")
builder.set_source("_strtools", """
int count_char(const char* s, char c) {
int count = 0;
for (; *s; s++)
if (*s == c) count++;
return count;
}
""")
builder.compile(verbose=False)
from _strtools import lib as strlib
print(strlib.count_char(b"mississippi", b"s")) # 4
print(strlib.count_char(b"banana", b"a")) # 3
Exercise 3: Cython Optimization
Take the pure Python function below and make it faster with Cython type declarations. Then measure the speedup with %timeit and compare the two results.
# Optimize this with Cython:
def mandelbrot_python(width, height, max_iter=100):
"""Generate a Mandelbrot set."""
result = []
for row in range(height):
for col in range(width):
c_real = (col - width / 2) * 4.0 / width
c_imag = (row - height / 2) * 4.0 / height
z_real, z_imag = 0.0, 0.0
iteration = 0
while z_real * z_real + z_imag * z_imag < 4.0 and iteration < max_iter:
z_real, z_imag = z_real * z_real - z_imag * z_imag + c_real, 2 * z_real * z_imag + c_imag
iteration += 1
result.append(iteration)
return result
# Hint: Add cdef declarations for all numeric variables
# Use %%cython magic in the next cell
# Write your Cython version here, starting the cell with %%cython
Show solution
%%cython
def mandelbrot_cython(int width, int height, int max_iter=100):
"""Generate a Mandelbrot set (Cython)."""
cdef list result = []
cdef int row, col, iteration
cdef double c_real, c_imag, z_real, z_imag, tmp
for row in range(height):
for col in range(width):
c_real = (col - width / 2.0) * 4.0 / width
c_imag = (row - height / 2.0) * 4.0 / height
z_real = 0.0
z_imag = 0.0
iteration = 0
while z_real * z_real + z_imag * z_imag < 4.0 and iteration < max_iter:
tmp = z_real * z_real - z_imag * z_imag + c_real
z_imag = 2 * z_real * z_imag + c_imag
z_real = tmp
iteration += 1
result.append(iteration)
return result
# In the next cell:
# py = mandelbrot_python(200, 100)
# cy = mandelbrot_cython(200, 100)
# print(sum(p != c for p, c in zip(py, cy)), "of", len(py), "pixels differ")
# %timeit mandelbrot_python(200, 100)
# %timeit mandelbrot_cython(200, 100)
Two things to notice in the solution:
width / 2.0instead ofwidth / 2: with Cints,/can mean integer division, so the float literal keeps the maths the same as the Python version.- When you compare the results, a handful of pixels (4 out of 20,000 on an Apple M-series Mac) may differ by one iteration. Those points sit exactly on the
|z|² = 4boundary, and the C compiler is allowed to fuse a multiply and an add into one instruction with slightly different rounding. That's floating-point arithmetic, not a bug in your code.
Quick recap
- Exercise 1 is the full
ctypesworkflow: write C, compile, set types, call, verify. - Exercise 2 shows how little code
cffiAPI mode needs for a new C function. - Exercise 3 is the usual Cython recipe: same algorithm, add
cdeftypes, then measure and compare.
Cleanup
This notebook created quite a few source files and compiled libraries in the current folder. Run the next cell to remove them.
# Clean up generated files
import glob
patterns = [
"mylib.c", "mylib.so", "mylib.dylib", "mylib.dll",
"structs.c", "structs.so", "structs.dylib", "structs.dll",
"callbacks.c", "callbacks.so", "callbacks.dylib", "callbacks.dll",
"mathtools.cpp", "mathtools*.so",
"mymodule.c", "mymodule*.so", "mymodule*.cpython*",
"setup.py", "_math_helpers*",
"matmul.*", "_strtools*", # from the exercise solutions
]
for pattern in patterns:
for f in glob.glob(pattern):
os.remove(f)
print(f"Removed: {f}")
# Remove build directory
import shutil
if os.path.exists("build"):
shutil.rmtree("build")
print("Removed: build/")
print("\nCleanup complete!")
Removed: mylib.c
Removed: mylib.dylib
Removed: structs.c
Removed: structs.dylib
Removed: callbacks.c
Removed: callbacks.dylib
Removed: mathtools.cpp
Removed: mathtools.cpython-312-darwin.so
Removed: mymodule.c
Removed: mymodule.cpython-312-darwin.so
Removed: setup.py
Removed: _math_helpers.o
Removed: _math_helpers.c
Removed: _math_helpers.cpython-312-darwin.so
Removed: build/
Cleanup complete!
Summary & Cheat Sheet
| What We Covered | Key Takeaway |
|---|---|
ctypes |
Built-in, load any shared library, manual type mapping |
cffi |
C-syntax declarations; ABI mode loads, API mode compiles |
| Python C API | Full control, lots of boilerplate, manual refcounting |
pybind11 |
Best for C++, automatic conversions, clean syntax |
| Cython | Python-like syntax compiles to C, easy optimization path |
| SWIG | Auto-generates bindings, good for multi-language support |
| Structs | Mirror C structs in Python with ctypes.Structure |
| Callbacks | Pass Python functions to C using CFUNCTYPE |
Commands to Remember
# ctypes
lib = ctypes.CDLL("./mylib.so")
lib.f.argtypes = [ctypes.c_double]; lib.f.restype = ctypes.c_double
# cffi
ffi = FFI(); ffi.cdef("double f(double);"); lib = ffi.dlopen("./mylib.so")
# C extension / pybind11 build
!{sys.executable} setup.py build_ext --inplace
# (add -undefined dynamic_lookup on macOS)
!c++ -O2 -shared -std=c++17 -fPIC $(python -m pybind11 --includes) m.cpp -o m$(python3-config --extension-suffix)
# Cython in Jupyter
%load_ext Cython
%%cython
What's Next
- Explore NumPy's C API and the buffer protocol for passing whole arrays without copying.
- Try
maturinandPyO3to write Python extensions in Rust. - Pick a library you use (NumPy, Pillow, pandas) and look at how it organises its C, C++ or Cython code.