Joel Holder

Math, coding, philosophy, and other things that interest me

View on GitHub
16 December 2019

Wrapping C and C++ APIs in Python

by Joel Holder

Python is usually where I want to orchestrate work: load data, shape inputs, run experiments, and glue systems together. C and C++ are where I want hot loops, existing native libraries, SIMD-heavy routines, mature systems code, and APIs that already exist outside the Python ecosystem.

The useful question is not whether Python or C is better. The useful question is where the boundary should be. If the native side has a small stable ABI, ctypes is often enough. If the boundary needs C++ types, exceptions, RAII, overloaded functions, or a clean Python-facing module, reach for pybind11.

Start with the ABI

For a C API, the minimum viable path is:

Using libgeohash as an example, a simple Linux build might look like this:

gcc -O3 -fPIC -shared -o libgeohash.so geohash.c

On macOS that output would usually be a .dylib; on Windows, a .dll. The Python code should not assume the extension unless you control deployment.

A small ctypes wrapper

The important part of ctypes is not the load call. It is declaring the ABI accurately. Without argtypes and restype, Python will guess, and those guesses are not a contract you want in production.

from ctypes import CDLL, c_char_p, c_double, c_int
from pathlib import Path

lib = CDLL(str(Path(__file__).with_name("libgeohash.so")))

def bind(name, restype, *argtypes):
    fn = getattr(lib, name)
    fn.restype = restype
    fn.argtypes = list(argtypes)
    return fn

geohash_encode = bind(
    "geohash_encode",
    c_char_p,
    c_double,
    c_double,
    c_int,
)

raw = geohash_encode(41.41845703125, 2.17529296875, 5)
hash_value = raw.decode("ascii")
print(hash_value)  # sp3e9

That wrapper is intentionally boring. A Python call crosses into native code, the C function receives two doubles and an int, and the returned char * is exposed as bytes. The Python layer decodes it because the C ABI does not know about Python strings.

Structs map cleanly when layout is simple

For plain C structs, define a matching ctypes.Structure. The order and C types must match the header. If the C side has packing pragmas, bitfields, platform-dependent integer sizes, or nested ownership, slow down and verify layout with tests.

typedef struct GeoBoxDimensionStruct {
    double height;
    double width;
} GeoBoxDimension;

extern GeoBoxDimension geohash_dimensions_for_precision(int precision);
from ctypes import Structure, c_double, c_int

class GeoBoxDimension(Structure):
    _fields_ = [
        ("height", c_double),
        ("width", c_double),
    ]

geohash_dimensions_for_precision = bind(
    "geohash_dimensions_for_precision",
    GeoBoxDimension,
    c_int,
)

dims = geohash_dimensions_for_precision(6)
print(dims.height, dims.width)

This is a good shape for ctypes: value types cross the boundary, Python receives a small struct, and there is no lifecycle problem to solve.

Out parameters and buffers

Many C APIs return status codes and write results through pointers. Model that directly. Do not pretend every C function is a Python function that returns one object.

int geohash_decode_bbox(
    const char *hash,
    double *lat_min,
    double *lat_max,
    double *lng_min,
    double *lng_max
);
from ctypes import POINTER, byref, c_char_p, c_double, c_int

geohash_decode_bbox = bind(
    "geohash_decode_bbox",
    c_int,
    c_char_p,
    POINTER(c_double),
    POINTER(c_double),
    POINTER(c_double),
    POINTER(c_double),
)

def decode_bbox(hash_value: str):
    lat_min = c_double()
    lat_max = c_double()
    lng_min = c_double()
    lng_max = c_double()

    rc = geohash_decode_bbox(
        hash_value.encode("ascii"),
        byref(lat_min),
        byref(lat_max),
        byref(lng_min),
        byref(lng_max),
    )
    if rc != 0:
        raise ValueError(f"geohash_decode_bbox failed: {rc}")

    return lat_min.value, lat_max.value, lng_min.value, lng_max.value

The wrapper converts the C style into a Python style at the edge: allocate output slots, call the native function, check the return code, and return a tuple. That keeps the rest of the Python application free of pointer management.

Use pybind11 when the boundary wants to be a module

ctypes works best against a C ABI. Once the native side is C++, or once you want a polished Python module instead of a thin ABI wrapper, pybind11 is usually cleaner. You write a small C++ binding layer, compile it as a Python extension, and expose Python-native functions/classes.

// bindings.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <stdexcept>
#include <string>

namespace py = pybind11;

extern "C" char *geohash_encode(double lat, double lng, int precision);

struct Box {
    double height;
    double width;
};

Box dimensions_for_precision(int precision) {
    // In real code this would call the native library.
    return Box{0.0054931640625, 0.010986328125};
}

std::string encode(double lat, double lng, int precision) {
    char *raw = geohash_encode(lat, lng, precision);
    if (raw == nullptr) {
        throw std::runtime_error("geohash_encode returned null");
    }
    return std::string(raw);
}

PYBIND11_MODULE(geohash_native, m) {
    m.doc() = "Native geohash bindings";

    py::class_<Box>(m, "Box")
        .def_readonly("height", &Box::height)
        .def_readonly("width", &Box::width);

    m.def("encode", &encode,
        py::arg("lat"),
        py::arg("lng"),
        py::arg("precision") = 12);

    m.def("dimensions_for_precision", &dimensions_for_precision);
}

A minimal build using setuptools can stay small:

# setup.py
from pybind11.setup_helpers import Pybind11Extension, build_ext
from setuptools import setup

ext_modules = [
    Pybind11Extension(
        "geohash_native",
        ["bindings.cpp", "geohash.c"],
        cxx_std=17,
    ),
]

setup(
    name="geohash_native",
    ext_modules=ext_modules,
    cmdclass={"build_ext": build_ext},
)
python -m pip install pybind11 setuptools wheel
python -m pip install -e .
import geohash_native

print(geohash_native.encode(41.41845703125, 2.17529296875, precision=5))

box = geohash_native.dimensions_for_precision(6)
print(box.height, box.width)

The tradeoff is build complexity. With ctypes, Python loads an existing shared object. With pybind11, you ship a compiled Python extension for each supported Python/platform/architecture combination. The upside is a nicer API, better C++ interop, and fewer pointer-shaped details leaking into application code.

Rules of thumb

The clean version is simple: keep Python in charge of orchestration, keep native code responsible for the expensive or already-existing work, and make the boundary explicit. A thin wrapper is often enough, but the moment the boundary becomes part of your product API, give it the same design attention as any other public interface.

tags: C# - Interop - Python - Speed