Bringing Native Libraries to Python: A ctypes Tutorial

Bringing Native Libraries to Python: A ctypes Tutorial

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.

<span class="figure-number">Figure 1: </span>The ctypes Workflow

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.

<span class="figure-number">Figure 2: </span>Off by One, an [xkcd classic](https://xkcd.com/3062/)

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:

  1. Load the C library as before.
  2. Define the C function signature using argtypes and restype.
  3. Define the comparator function in Python.
  4. 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:

<span class="figure-number">Figure 3: </span>How Callbacks Implemented in Python Are Handled

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 what qsort() 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.

Related Posts

Playing with the Ubiquiti G4 Doorbell Display

Playing with the Ubiquiti G4 Doorbell Display

We were able to port C/C++ programs to the G4 doorbell platform. We also tried to show something on the devices’ display.

Read More
Automatic Memory Checking for Your Unit-Tests

Automatic Memory Checking for Your Unit-Tests

There cannot be enough safety nets in software development. In this post, we will automatically run unit-tests with a memory checker.

Read More
Taming UB in C++ with static/dynamic analysis

Taming UB in C++ with static/dynamic analysis

This article presents static and dynamic code anlaysis tools which help to detect programming errors leading to undefined behavior.

Read More