
Bringing Native Libraries to Python: A ctypes Tutorial
- Rainer Poisel
- Coding
- April 16, 2025
Table of Contents
This is an introduction to creating Python bindings for existing native code libraries. We will demonstrate the capabilities of Python’s ctypes module.
Introduction
I needed to expose a well-established and complex C++ library to Python. The library was structured similarly to the Windows API, with numerous functions, macros, and types spread across multiple headers – making a full rewrite or hand-written Python bindings a time-consuming and error-prone task. Fortunately, Python is powerful and flexible enough to integrate with native C or C++ libraries, allowing us to combine the performance of compiled code with the ease of use and rapid development Python offers.
I evaluated several options for creating bindings and sorted them by suitability for wrapping large legacy libraries and personal preference:
- ctypes: Built into Python and easy to use for small tasks, but managing function signatures manually doesn’t scale well for large C/C++ APIs.
- SWIG: Automatically generates Python bindings from C/C++ headers. Ideal for large or legacy codebases with minimal manual effort.
- pybind11: Modern and clean C++11/14 bindings for Python. Great for small to medium projects, but requires writing wrapper code manually.
- native: Low-level, manual binding via Python’s C runtime. Powerful but complex and error-prone, with steep learning curve.
- Cython: A compiler that turns Python-like code into optimized C extensions. Good for performance-critical code or wrapping C libraries.
- cffi: Easier to use than `ctypes`, with better type support. Designed for C, but offers limited support for C++.
In this post I will start by evaluating the possibilities of the ctypes
foreign function library built into the Python standard library. Using this library, it is easy to get started right away: nothing needs to be available in a build environment. The actual Python application instantiates the wrapper for the intended legacy library based on a hand-crafted mapping.
The impatient among you can wrap their heads around the companion source code which is hosted on GitHub.
Getting Started with ctypes
The ctypes
library is a foreign function interface (FFI) library for Python. It allows you to call C functions and access C data types in shared libraries directly from Python. Unlike tools like Cython or SWIG, ctypes
doesn’t require writing additional C code or compiling helper modules. The following figure shows how the different compontens relate to each other: Python
ships with the ctypes
module. The underlying operating system provides the DynamicLinker
and allows for calling Native Code
of the provided Library
.
Figure 1: The ctypes Workflow
Calling puts and printf
Let’s get started by wrapping a library that is well-known and installed on virtually any system: the C library. We will implement the “Hello, World!” classic by calling the C-library’s puts()
function. According the the man-page, the function definition is like this:
1 #include <stdio.h>
2
3int puts(const char *s);
Calling it is straight forward:
1from ctypes import CDLL, c_char_p, c_int
2
3
4def test_ctypes_puts_c_str() -> None:
5 libc = CDLL("libc.so.6")
6
7 puts = libc.puts
8 puts.argtypes = [c_char_p]
9 puts.restype = c_int # default; also set implicitly
10
11 puts("Hello, Wörld!\n".encode())
This example demonstrates how to call the simple puts()
C function from Python using ctypes
. The standard C library is dynamically loaded with CDLL
, making its exported functions accessible as attributes of the resulting object. To ensure correct and safe function invocation, we explicitly define the expected argument and return types using argtypes
and restype
. This enables ctypes
to validate the types at runtime and can help catch bugs early. In this case, a UTF-8 encoded, null-terminated byte string (“Hello, Wörld!”, with an umlaut that is) is passed to puts()
. Note that the library name (libc.so.6
) is Linux-specific and may differ across platforms.
In order for printf()
to work with any number of argument, we omit setting argtypes
. The following code works as expected in case we pass in a value for the format string containing %d
:
1from ctypes import CDLL, c_int
2
3
4def test_ctypes_printf_int() -> None:
5 libc = CDLL("libc.so.6")
6
7 printf = libc.printf
8 # printf is variadic, so no argtypes specified
9
10 printf("The value is: %d\n".encode(), c_int(-42))
Unlike puts()
, the printf()
function is variadic, meaning it accepts a variable number of arguments. This presents a challenge when using ctypes
, because the argtypes
attribute – which usually enables runtime type checking – does not work with variadic functions. As a result, Python cannot verify that the arguments passed to printf()
match the expected types. This can be dangerous: without argtypes
, all runtime checks are effectively disabled. If a required argument such as c_int(-42)
is omitted or passed incorrectly, the result is undefined behavior (UB) and may lead to segmentation faults.
](./off_by_one.png)
Figure 2: Off by One, an xkcd classic
In the next step we will wrap a function that requires to provide a callback function: qsort()
. As you will see, it is possible to provide Python functions as callback functions for functions implemented in C.
Calling a Function that Requires a Callback: qsort
One of the more powerful features of ctypes
is the ability to pass Python functions as C function pointers – a typical requirement when working with callback-based C APIs. Let’s explore this by wrapping and calling the C standard library’s qsort()
function, which expects a user-defined comparison function passed in as a function pointer. The man-page of the qsort()
function shows its definition in the GCC dialect of the C23 standard of the C programming language:
1#include <stdlib.h>
2
3void qsort(void base[.size * .n], size_t n, size_t size,
4 typeof(int (const void [.size], const void [.size]))
5 *compar);
The comparator function pointed to by the compar
parameter must return an integer: less than zero if the first argument is less than the second, zero if equal, and greater than zero if the first is greater. To implement the same behavior in Python, we need to take the following steps:
- Load the C library as before.
- Define the C function signature using
argtypes
andrestype
. - Define the comparator function in Python.
- Wrap the Python function as a C function pointer using
CFUNCTYPE
.
We’ll sort a simple array of integers.
1from ctypes import CDLL, CFUNCTYPE, POINTER, c_int, sizeof
2from typing import Generic, Protocol, TypeVar, runtime_checkable
3
4CMPFUNC = CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
5
6T = TypeVar("T", covariant=True)
7
8
9@runtime_checkable
10class PtrType(Generic[T], Protocol):
11 def __getitem__(self, index: int) -> T: ...
12
13
14def test_ctypes_qsort() -> None:
15 @CMPFUNC
16 def py_cmp_func(a: PtrType[int], b: PtrType[int]) -> int:
17 return a[0] - b[0]
18
19 libc = CDLL("libc.so.6")
20 qsort = libc.qsort
21 qsort.argtypes = [c_void_p, c_size_t, c_size_t, CMPFUNC]
22 qsort.restype = None
23
24 IntArray5 = c_int * 5
25 ia = IntArray5(5, 1, 7, 33, 99)
26
27 qsort(ia, len(ia), sizeof(c_int), py_cmp_func)
28
29 assert IntArray5(1, 5, 7, 33, 99) != ia, "references must not be equal"
30 assert [1, 5, 7, 33, 99] == [elem for elem in ia], "array elements must be equal"
The key mechanism here is CFUNCTYPE
, which allows us to wrap a Python callable into a C function pointer.
Behind the scenes, this:
- Allocates a trampoline function in the Python interpreter’s native code.
- Ensures the Python interpreter is prepared to call your Python callback function via its handler.
- Automatically converts simple types (e.g., integer return values); complex types must be managed manually.
The following UML diagram visualizes these relations/steps:
Figure 3: How Callbacks Implemented in Python Are Handled
It is very important to retain a reference to your callback in Python (e.g., by using a global or nested function) as long as the C code might call it. Otherwise, Python’s garbage collector might deallocate the function, which results in undefined behavior – usually a segmentation fault. See the Python docs for more information on that topic.
In case something goes wrong with the Python callback provided to a C function, here are some debugging tips:
- Double-check that the comparator signature passed to
CFUNCTYPE
matches exactly whatqsort()
expects. - Print intermediate comparisons inside your Python comparator to verify logic.
- Beware of Python reference lifetimes – don’t allow the comparator function object to be prematurely garbage collected.
We’re using a custom Protocol
(PtrType
) to statically describe the behavior of ctypes
pointers for better type checking and IDE support. This has no effect at runtime – it’s purely for development. The a[0]
syntax works because ctypes
pointers support __getitem__()
. These type hints improve editor feedback and help catch mistakes early in tools like Pyright, PyCharm, or other LSP-based approaches. You can also write the comparator to use .contents.value
of the underlying POINTER(c_int)
object, which is semantically closer to the C-style dereferencing of pointers:
1@CMPFUNC
2def py_cmp_func(a, b) -> int:
3 return a.contents.value - b.contents.value
Wrapping a Custom C++ Library
To demonstrate how to expose C++ code to Python, we will wrap a simple C++ class, compile it into a shared library, and then access that functionality from Python using the ctypes
module. The goal here is to understand the fundamental mechanics of sharing compiled C++ and C code with Python. As we will see, C code can be called directly and C++ code needs a C wrapper first. To follow along, you need CMake and the GNU C++ compiler installed. However, the sample code base also includes a docker compose environment that includes all prerequisites.
Introducing the Custom C++ Library
Here’s a simple Rectangle
class we want to expose via Python. It will serve as the native library’s core:
1#pragma once
2
3class Rectangle {
4public:
5 Rectangle(int a, int b) : a{a}, b{b} {}
6
7 int area() const;
8
9private:
10 int a;
11 int b;
12};
And the corresponding implementation:
1#include <geometry.h>
2
3int Rectangle::area() const {
4 return a * b;
5}
We’ll now turn this into a shared object (.so
) using CMake:
cmake_minimum_required(VERSION 3.14)
project(geometry VERSION 0.1.0 LANGUAGES C CXX)
add_library(geometry SHARED geometry.cpp)
target_include_directories(geometry PUBLIC ${CMAKE_CURRENT_LIST_DIR})
In the next step we generate the actual build scripts and run them to build our shared library:
cmake -S . -B build -G Ninja
cmake --build build --verbose
ls -la build/libgeometry.so
As a result, you should now see something like:
-rwxrwxr-x 1 user user 15624 Mar 30 23:23 build/libgeometry.so
What’s in the Shared Object?
Let’s examine the contents using nm
:
nm build/libgeometry.so
By default, C++ functions are name mangled symbol names encoded with type and scope info to support function overloading. The geometry library containing the Rectangle
class from above looks like this:
# ...
00000000000011de W _ZN9RectangleC1Eii # Rectangle::Rectangle(int, int)
000000000000113a T _ZNK9Rectangle4areaEv # Rectangle::area() const
# ...
C++ symbol names include type and scope information to support features like function overloading. These mangled names can be translated back to human-readable form using tools like c++filt
or nm --demangle
. However, we intentionally show the mangled versions here, since these are the actual symbol names the dynamic linker uses – important when interfacing with native code from Python using ctypes
. To provide ctypes
with predictable, unmangled symbols, we use a C-compatible wrapper. The extern "C" {}
block instructs the C++ compiler not to mangle the enclosed function names, exposing them with plain C linkage.
1#pragma once
2
3#include <geometry.h>
4
5#ifdef __cplusplus
6extern "C" {
7#endif
8
9Rectangle* rectangle_new(int a, int b);
10void rectangle_delete(Rectangle *r);
11int rectangle_area(Rectangle const *r);
12
13#ifdef __cplusplus
14}
15#endif
The implementation of these function just forwards to their C++ counterparts:
1#include <geometry.h>
2#include <geometry_wrap.h>
3
4Rectangle *rectangle_new(int a, int b) {
5 return new Rectangle(a, b);
6}
7
8void rectangle_delete(Rectangle *r) {
9 delete r;
10}
11
12int rectangle_area(Rectangle const *r) {
13 return r->area();
14}
Now we need to update the CMakeLists.txt
to include the wrapper:
cmake_minimum_required(VERSION 3.14)
project(geometry VERSION 0.1.0 LANGUAGES C CXX)
add_library(geometry SHARED
geometry.cpp
geometry_wrap.cpp)
target_include_directories(geometry PUBLIC ${CMAKE_CURRENT_LIST_DIR})
After rebuilding, we run nm
again to check for the new functions with C-style interface:
nm build/libgeometry.so
We can see that functions with their declarations surrounded by extern "C" {}
have their names unchanged in the resulting shared object which is ideal for usage with ctypes
.
# ...
00000000000011de W _ZN9RectangleC1Eii # Rectangle::Rectangle(int, int)
000000000000113a T _ZNK9Rectangle4areaEv # Rectangle::area() const
# ...
00000000000011c4 T rectangle_area # unchanged names from here
000000000000119f T rectangle_delete
0000000000001154 T rectangle_new
# ...
Implementing the ctypes Binding in Python
In the next step we create a minimal Python interface to the library using ctypes
. Make sure to set the argtypes
and restype
members of each function handle. If not provided, Python will assume default behavior (usually int
) which leads to unpredictable crashes or memory issues when dealing with pointers.
1import ctypes
2from collections.abc import Generator
3from contextlib import contextmanager
4from ctypes import c_int, c_void_p
5
6lib = ctypes.CDLL("libgeometry.so")
7
8lib.rectangle_new.argtypes = [c_int, c_int]
9lib.rectangle_new.restype = c_void_p
10
11lib.rectangle_delete.argtypes = [c_void_p]
12lib.rectangle_delete.restype = None
13
14lib.rectangle_area.argtypes = [c_void_p]
15lib.rectangle_area.restype = c_int
There are several ways to expose these C-style functions to Python. When designing an interface, it’s essential to consider the differences in resource management between C/C++ and Python: in C/C++, memory allocation and deallocation are manual and explicit, whereas in Python, the garbage collector automatically reclaims memory when an object’s reference count drops to zero. In the following examples, we demonstrate two approaches: an object-oriented wrapper that defers memory cleanup to Python’s garbage collector, and a context manager-based variant that provides more explicit and deterministic control over resource release.
The object-oriented wrapper defines a Python class that mirrors the behavior of the native Rectangle
class. It encapsulates the raw pointer returned by rectangle_new
and ensures proper cleanup in the __del__
method by calling rectangle_delete
. This design integrates naturally with Python’s object model, providing an intuitive and Pythonic interface while abstracting away the low-level memory management details:
1class ManagedRectangle:
2 def __init__(self, a: int, b: int) -> None:
3 self._obj = lib.rectangle_new(a, b)
4 if not self._obj:
5 raise MemoryError("Failed to allocate Rectangle")
6
7 def __del__(self):
8 if self._obj:
9 lib.rectangle_delete(self._obj)
10 self._obj = None
11
12 def area(self) -> int:
13 return lib.rectangle_area(self._obj)
This test demonstrates how to use the object-oriented Python wrapper for the native Rectangle
class. It creates an instance, calls the `area` method, and verifies the result.
1from pygeometry import ManagedRectangle
2
3
4def test_ctypes_pygeometry_rectangle() -> None:
5 r = ManagedRectangle(2, 3)
6 assert r.area() == 6
The context manager-based wrapper uses Python’s with
statement to manage the lifetime of the underlying C++ object. It returns a lightweight wrapper around the native handle and ensures that resources are released deterministically via rectangle_delete
at the end of the block. This approach aligns with RAII principles and is particularly useful in environments where deterministic cleanup is important or where explicit object ownership helps avoid resource leaks:
1class ScopedRectangle:
2 def __init__(self, handle: c_void_p) -> None:
3 self._handle = handle
4
5 def area(self) -> int:
6 return lib.rectangle_area(self._handle)
7
8
9@contextmanager
10def rectangle_new(a: int, b: int) -> Generator[R, None, None]:
11 h: c_void_p | None = None
12 try:
13 h = lib.rectangle_new(a, b)
14 if not h:
15 raise MemoryError("Could not allocate memory for new rectangle.")
16 yield R(h)
17 finally:
18 if h:
19 lib.rectangle_delete(h)
This following test shows how to use the context manager-based wrapper to create and use an object of the R
class. Again, it checks that the area is computed correctly. The context manager ensures that resources allocated by rectangle_new()
are properly released when the block exits.
1from pygeometry import rectangle_new
2
3
4def test_ctypes_pygeometry_rectangle_context() -> None:
5 with rectangle_new(2, 3) as r:
6 assert r.area() == 6
Conclusion and Outlook
This article provided an overview of different approaches for creating Python interfaces to existing native libraries written in C and C++. We began by calling simple C standard library functions and a function that requires passing a function pointer as callback – implemented entirely in Python. In the second part, we wrapped a custom C++ library using two different techniques: managed and scoped resources. We also highlighted key limitations of ctypes
such as being only able to wrap symbols with unmangled names.
The companion source code is hosted in a Git repository on GitHub. In practice, it is possible to bind arbitrarily complex libraries. One impressive example of this is PySDL3, a project that exposes the full SDL3 library to Python using ctypes
.
For my own projects, I ultimately chose SWIG (Simplified Wrapper and Interface Generator) due to its ability to automatically generate Python bindings directly from existing C/C++ headers. For large and mature codebases, SWIG offers a scalable and maintainable solution that avoids the need to manually write wrapper code at every layer. The next article in this series will demonstrate how to use SWIG to expose a complete C++ library to Python, including how to integrate it into the build process using setuptools
.