ctypes is the Python standard library's bridge to C. It loads a shared library at runtime (a .so, .dll, or .dylib), looks up functions inside it, and lets the caller invoke them as if they were Python functions. No compilation, no build system, no separate language. The trade-off is working close to the metal: every type has to be declared by hand, memory ownership is the caller's problem, and mistakes crash the interpreter instead of raising Python exceptions. This lesson covers how to use ctypes correctly, the common pitfalls, and how it compares to its modern alternative, cffi.
Often it is not needed. NumPy, Pandas, hashlib, json, re, and sqlite3 are already C underneath. A pure-Python loop using them is fast enough for most workloads. Three situations call for ctypes:
libpricing.so for inventory pricing and the application needs to call it from Python.sqrt, pow, or platform-specific math functions out of libm without setting up a build.For "my Python code is slow", ctypes is rarely the right answer. Profile first, try NumPy or a better algorithm, and turn to ctypes only when there is a real C library to call.
Crossing the Python-C boundary is not free. Each call has fixed overhead (argument marshalling, type checks, the GIL dance). Calling C a million times in a tight Python loop can be slower than the same loop in pure Python. Push the loop into C; do not call C inside a Python loop.
ctypes provides three loader classes for the three common calling conventions.
| Loader | Convention | Used for |
|---|---|---|
CDLL | cdecl | Most Unix shared libraries, and most Windows DLLs |
WinDLL | stdcall | Older Windows APIs (the Win32 API itself, GDI, USER) |
OleDLL | stdcall + COM error handling | COM-style APIs that return HRESULT |
For cross-platform code, CDLL is the starting point. The library name follows the platform's conventions: libm.so.6 on Linux, libSystem.dylib (which contains the math functions) on macOS, msvcrt.dll on Windows.
CDLL("...") calls dlopen (or its Windows equivalent) to load the library into the process. Once the library is loaded, every exported function shows up as an attribute on the libm object.
ctypes.cdll.LoadLibrary("libname") is an alternative syntax. It returns the same kind of CDLL object and is sometimes used in older code. Either works.
The library path can be absolute or a bare name. With a name, the platform's dynamic linker searches its standard locations (/usr/lib, /usr/local/lib, LD_LIBRARY_PATH on Linux; DYLD_LIBRARY_PATH on macOS; PATH and System32 on Windows). For shipped libraries, use the absolute path or arrange for the runtime to find them.
The library is a compiled object file on disk. Loading it makes its symbol table available. The Python side gets a handle for looking up function symbols by name.
Every Unix system has libm, the C math library. It contains sqrt, pow, sin, cos, and related functions. A first call looks like this:
Four things happen here:
CDLL(libname) opens the shared library.libm.sqrt looks up the symbol named sqrt and returns a callable function pointer.argtypes tells ctypes how to convert the Python float into a C double before the call.restype tells ctypes how to interpret the 8 bytes that come back.The argtypes and restype assignments look optional. They are not. If they are skipped, ctypes defaults to int for both arguments and return value, which corrupts anything that uses double, pointers, or structs.
What is wrong with this code?
The C function sqrt takes a double and returns a double, but without argtypes, ctypes passes 2.0 as an int (which becomes 2). Without restype, the 8-byte return value is interpreted as a 4-byte int, and the bits read back are meaningless when treated as an integer. The number printed is whatever the upper or lower 4 bytes of the returned double happen to look like.
Fix:
Always declare types before the first call.
Looking up a function symbol on a CDLL object hits a dictionary internally and caches the result. The first call to libm.sqrt is slightly slower than the second. For hot paths, store the callable in a local variable rather than going through the library object every iteration.
ctypes exposes a Python type for every C primitive. The naming follows a clear pattern: c_int, c_double, c_char, c_void_p, and so on.
| C type | ctypes type | Python type for input |
|---|---|---|
int | c_int | int |
unsigned int | c_uint | int |
long | c_long | int |
long long | c_longlong | int |
float | c_float | float |
double | c_double | float |
char | c_char | bytes of length 1 |
char * (C string) | c_char_p | bytes |
wchar_t * (wide string) | c_wchar_p | str |
void * | c_void_p | int (raw address) or None |
T * (typed pointer) | POINTER(T) | array, byref(obj), or None |
size_t | c_size_t | int |
Strings are a common source of mistakes. C strings are byte sequences terminated by a null byte, not Unicode text. To pass a Python str, encode it first; to read a returned char *, decode it.
Encoding to bytes is mandatory. Passing a Python str to a c_char_p argument without .encode(...) raises TypeError: bytes or integer address expected. Going the other direction (a C function returns char *), ctypes gives back a bytes object; call .decode("utf-8") to get a Python string.
For wide strings (Windows APIs that use UTF-16), c_wchar_p accepts a Python str directly because wchar_t is already a Unicode code unit.
C functions often work on arrays of values. ctypes builds an array type by multiplying a type by a count: ctypes.c_int * 5 is "an array of 5 c_ints".
The array object behaves like a fixed-length sequence: it is indexable, iterable, and passable to a C function as if it were a pointer to the first element. C accepts this because C arrays decay to pointers in function calls anyway.
For passing a single object by pointer, ctypes.byref(obj) is the fast option. It produces a lightweight pointer to obj without making a copy.
The c_double wrapper holds a mutable double whose .value attribute can be read and written from Python. C functions that take double * and write through it can update x.value in place. This is how "output parameters" are implemented in ctypes.
ctypes.POINTER(T) is the type-construction form, used in argtypes. ctypes.POINTER(ctypes.c_double) is the type for "pointer to double", and a byref(c_double_instance) is passed to a function declared that way.
C programs pass complex data as structs. To call them, mirror the struct on the Python side by subclassing ctypes.Structure and declaring _fields_ in the same order and with matching types as the C definition.
Consider an inventory system that ships a C function for computing the discounted price of a product:
The implementation is one line:
Compiled once with gcc -shared -fPIC -o libpricing.so pricing.c (or the macOS/Windows equivalent), it becomes a shared library. From Python:
_fields_ must match the C struct's field order and types exactly. ctypes.POINTER(Product) is the type used in argtypes; ctypes.byref(headphones) passes the address of headphones without making a copy. Both choices matter and are common sources of mistakes.
The flow is direct: Python allocates the struct, byref hands its address to C, C reads the fields by offset, and the result comes back as a double. If the C function wrote to p->price, the change would be visible from Python through headphones.price after the call.
An important detail: ctypes does not validate _fields_ against the real C struct. If price is declared as c_int (4 bytes) but the actual struct stores double (8 bytes), every field after price ends up at the wrong offset and reads or writes the wrong bytes. The errors are usually subtle: numbers that look almost right, occasional crashes, mysterious corruption. There is no compile-time check. The layout has to be specified correctly by hand.
For structs with padding requirements (alignment), ctypes follows the same rules as a C compiler on the same platform. For a different layout (packed, no padding), set _pack_ = 1 on the class. For most C structs, the defaults work.
ctypes does not track memory ownership across the Python-C boundary. When a C function returns a pointer, the caller must know who allocated it and who is responsible for freeing it. Three common patterns:
1. The C library returns a pointer to a static buffer.
Some functions return pointers to internal storage that the library owns forever. The data can be read but free must not be called on it. The classic example is strerror(errno), which returns a pointer to a static message buffer.
2. The C library returns a `malloc`'d pointer that the caller must free.
Other functions allocate fresh memory and hand it over. Forgetting to call free leaks memory; calling free on a buffer that was not malloc'd crashes the program. The library's documentation specifies which case applies.
A few choices in this code are intentional. The restype is c_void_p, not c_char_p, because c_char_p would convert the pointer to a Python bytes object and lose the raw address before free could be called. ctypes.string_at(ptr) copies the C string into a Python bytes object without consuming the pointer, so it can still be passed to free.
If restype had been c_char_p, this code would leak memory: Python would never see the raw address again, and the malloc'd block would stay alive until the process exits.
3. The Python side allocates a buffer and passes it to C.
When the C function fills a buffer the caller provides, Python owns the memory and ctypes handles cleanup when the buffer goes out of scope.
create_string_buffer(size) allocates a Python-owned byte buffer of the given size. Pass it to a C function as a char * argument, the function writes into it, and the program reads it back from Python. The buffer is freed when nothing in Python holds a reference to it, by normal refcounting.
Forgetting to free a malloc'd return value leaks memory on every call. In a loop or a long-running service, the leak compounds quickly. The fix is to keep the raw pointer (c_void_p), copy the data into Python (string_at, memmove, or a buffer), and call the library's free (or whatever cleanup function it documents).
A short list of things that cause real bugs.
1. Forgetting `argtypes` and `restype`.
The defaults are int for both. Anything that uses double, pointers, or structs produces garbage without them.
2. Wrong field order or types in a `Structure`.
No compile-time check exists. Mismatches produce silent corruption that is hard to diagnose because the call does not fail; it returns wrong numbers or crashes much later.
3. Mismatched library architecture.
A 32-bit Python cannot load a 64-bit .so, and vice versa. The error message is rarely friendly. On macOS, an Intel-only library cannot be loaded by an Apple Silicon Python (unless the library is built as a universal binary), and the error sometimes says "image not found" even when the file is present.
4. Calling convention mismatch on Windows.
CDLL uses cdecl, which the C runtime and most user-built libraries use. Some older Windows APIs use stdcall, which needs WinDLL. Mixing them up corrupts the stack on every call. Modern Windows APIs are usually cdecl (or close enough), but a few legacy ones are not.
5. The GIL is held by default during the call.
Calling a long-running C function (a 5-second crypto computation, say) blocks every other Python thread. ctypes does not release the GIL automatically. For long C calls, run them in a separate worker thread or use a library that releases the GIL explicitly.
6. Mutable arguments do not update without `byref` or `POINTER`.
For a C function to modify a Python-side c_double via a double * parameter, pass ctypes.byref(value), not value. Passing the value directly hands C a copy on its stack; any writes are invisible to Python.
7. Strings without encoding.
c_char_p accepts bytes, not str. Forgetting to .encode("utf-8") raises TypeError. Forgetting to .decode("utf-8") on a returned bytes value works but leaves the result as a byte string.
8. Keeping callbacks alive.
When passing a Python function as a C callback, store the wrapped callback in a variable that outlives the C library's interest in calling it. Otherwise it gets garbage collected and C crashes when it tries to call the dead pointer.
The lambda is wrapped in a CFUNCTYPE, and the wrapper is stored in a module-level list so it stays alive for the lifetime of the program (or until it is removed). Without that, the wrapper is collected after the call statement and C crashes the next time it tries to invoke the dead pointer.
ctypes is one of several tools for talking to C. The choice depends on the goal and the size of the binding.
| Tool | Install | Type declarations | Compile step | Best for |
|---|---|---|---|---|
ctypes | Stdlib | Python objects (c_int, c_double, ...) | Never | Quick scripts, small bindings, no extra dependencies |
cffi | pip install cffi | C syntax in ffi.cdef(...) strings | Optional (ABI mode no, API mode yes) | Larger bindings, importing real headers, production libraries |
PyBind11 | pip install pybind11 plus a compiler | C++ classes and templates | Always | Writing new C++ extensions, exposing C++ classes to Python |
| Cython | pip install cython | Python with type hints | Always | Speeding up pure-Python loops, mixed Python/C code |
| PyO3 | Rust crate plus pip install | Rust types | Always (Cargo) | New native extensions in Rust |
cffi is the natural next step after ctypes. Instead of declaring each C type by hand, paste C source into ffi.cdef(...), and cffi parses it and generates the binding. For a binding to a library with dozens of functions and structs, cffi is much less painful, and the API mode runs a real C compiler at install time to catch type mismatches.
PyBind11 is the C++ option. It is heavier (always requires compilation, depends on C++ templates) but it makes exposing C++ classes, RAII, and templates much cleaner than ctypes could. Most modern C++ libraries that ship Python bindings use PyBind11.
A rule of thumb: for a small number of C functions and no build step, use ctypes. For a real C library and a header file, use cffi. For C++ from scratch, use PyBind11. For performance-critical Python that does not have a C library backing it, use Cython or PyO3.
A common mistake is reaching for ctypes before profiling. The order of operations matters:
cProfile, time.perf_counter, py-spy. No guessing.An example: a checkout pipeline spends 200ms summing line items for orders with 1,000 products. A hand-written ctypes extension might shave that to 5ms, but np.sum on a NumPy array gets to 8ms with no compilation, no new dependency, and no foreign-function-interface headaches. Python wins most of the time.
The other reason to avoid ctypes is operational pain. A pure-Python package installs anywhere with pip install. A ctypes-based package needs the shared library to be present at runtime on every machine. Build wheels and bundle the library, or "library not found" errors will appear during deployment.
ctypes is the right call for measured needs: wrapping a real C library, calling OS-level APIs, or hooking into a system feature Python does not expose. It is the wrong call for "Python feels slow." The bottleneck is usually something else.
10 quizzes