Python C Extensions: Part V — ctypes Handle Pool
- Section 9: ctypes Handle Pool for C++
- 9.1 Why a Handle Pool Behind ctypes
- 9.2 Three-Layer Architecture
- 9.3 C++ Layer: HandleObject and HandlePool
- 9.4 C ABI Bridge: extern “C” Without Python.h
- 9.5 Python Layer: CDLL, HandleResource, Typed Wrappers
- 9.6 Complex Types as Input (Accept Structs)
- 9.7 Complex Types as Output — Out-Parameter and Return-by-Value
- 9.8 Complex Types as Output — Handle-Return Pattern
- 9.9 Type Safety: TypeId and get_as
- 9.10 Extending the Framework
- 9.11 Comparison Across the Series
- References
This article is Part V of the Python C extension series. Part I — Overview covers hand-written extensions and PyCapsule opaque handles. Part II — Execution covers bytecode vs C method dispatch. Part III — ctypes and CFFI covers loading plain C libraries through _ctypes and libffi. Part IV — Complex ctypes Structs and Handles covers struct mirroring, keepalive, and the generalized handle idea behind ctypes.Structure. Part VI — ROS 2 Message Bindings applies the capsule pattern to ROS 2 rosidl Python bindings and rclpy.
Part V introduces a layered handle-pool pattern for binding C++ through ctypes: an opaque int64_t handle indexes objects in a central HandlePool, a thin extern "C" bridge exposes stable symbols to _ctypes, and Python wrapper classes mirror each C++ type. The demo also shows four ways to move complex data across the C ABI — struct input, out-parameter output, return-by-value, and handle-return for complex results.
Runnable demo: c_ext_handle_binding in the python repository.
make -C c_ext_handle_binding
python3 c_ext_handle_binding/test_handle_binding.py
| Section | Demo folder |
|---|---|
| §9.2–9.11 | c_ext_handle_binding — cpp/, python/, test_handle_binding.py |
| §9.11 contrast (capsule) | c_ext_capsule_config (Part I §2.1) |
| §9.11 contrast (ctypes struct) | ctypes_complex_struct (Part IV) |
Section 9: ctypes Handle Pool for C++
9.1 Why a Handle Pool Behind ctypes
Part III showed ctypes calling scalar C functions in a plain .so. Part IV showed mirroring C structs with ctypes.Structure, keepalive, and user API classes above internal handles.
Those patterns work well for plain C and transient struct marshalling. They become awkward when:
- The backing implementation is C++ with classes, virtual destructors, and
std::string/ containers. - You need long-lived objects whose addresses must not cross the ctypes boundary (C++ ABI is not ctypes-safe).
- Several distinct C++ types share one FFI entry point and must be dispatched safely at runtime.
- Complex results should outlive a single FFI call without copying large structs on every access.
Part I’s PyCapsule already solves opaque handles — but only inside a hand-written C extension (Python.h, PyInit_*). Part V keeps stdlib-only Python glue (ctypes) while adopting the same idea: an opaque token on the Python side, real object owned elsewhere.
The handle-pool pattern uses an int64_t integer as the token (Wikipedia’s handle model). Value 0 means invalid. The pool maps 1, 2, 3, … to std::unique_ptr<HandleObject> instances. Python never sees C++ pointers.
9.2 Three-Layer Architecture
The demo splits responsibilities into three layers. Each layer has a narrow job; extension means adding types, not rewriting the core.

| Layer | Location | Responsibility |
|---|---|---|
| C++ | cpp/*.hpp, cpp/*.cpp |
Business logic, HandleObject interface, HandlePool storage and lifecycle |
| C ABI | cpp/bridge.cpp |
extern "C" functions only — stable ctypes surface, no Python.h |
| Python | python/*.py |
CDLL load, argtypes/restype, HandleResource base, per-type wrappers |
Directory layout:
c_ext_handle_binding/
├── cpp/
│ ├── types.hpp # HandleId, TypeId enum
│ ├── handle_object.hpp # abstract HandleObject
│ ├── handle_pool.hpp/.cpp # store / get / release / get_as
│ ├── c_types.h # C structs for ctypes (ConfigSpec, …)
│ ├── config.hpp/.cpp # demo business type 1
│ ├── counter.hpp/.cpp # demo business type 2
│ ├── *_resource.hpp/.cpp # complex result types stored as handles
│ └── bridge.cpp # extern "C" exports
├── python/
│ ├── _native.py # CDLL + signatures + error helpers
│ ├── handle.py # HandleResource base
│ ├── types.py # ctypes.Structure mirrors
│ ├── config.py, counter.py # business wrappers
│ └── *_resource.py # wrappers for handle-returned results
├── Makefile # g++ -shared → libhandle_bridge.so
└── test_handle_binding.py
Build produces libhandle_bridge.so — a plain shared library, not a mymodule.cpython-….so C extension. Python loads it with ctypes.CDLL, same class of dynamic linking as Part III §7.2.
9.3 C++ Layer: HandleObject and HandlePool
Full sources: handle_object.hpp, handle_pool.hpp, types.hpp
Every C++ instance managed by the pool implements a minimal abstract interface:
class HandleObject {
public:
virtual ~HandleObject() = default;
virtual TypeId type() const = 0;
};
No business logic in the base class — Config::process(), Counter::increment(), and so on stay in concrete types. The base exists so the pool can store heterogeneous objects and delete them through a virtual destructor.
TypeId is an explicit enum (no RTTI across the C ABI):
enum class TypeId : int {
Config = 1,
Counter = 2,
ConfigSnapshot = 3,
ProcessSummary = 4,
CounterStats = 5,
};
HandlePool owns all objects:
class HandlePool {
public:
HandleId store(std::unique_ptr<HandleObject> obj);
HandleObject* get(HandleId handle);
bool release(HandleId handle);
int type_of(HandleId handle) const;
template <typename T>
T* get_as(HandleId handle, TypeId expected);
private:
HandleId next_id_ = 1;
std::unordered_map<HandleId, std::unique_ptr<HandleObject>> handles_;
};
storeassigns the next monotonic id (starting at1), moves theunique_ptrinto the map, returns the id.releaseerases the entry; destroying theunique_ptrruns the concrete destructor.get_as<T>— lookup plus type check (§9.9).
Demo business types: Config (mirrors Part I §2.1 capsule semantics), Counter (simple mutable counter).
Demo result resource types: ConfigSnapshotResource, ProcessSummaryResource, CounterStatsResource — C++ objects that hold complex data and are returned to Python as new handles (§9.8).
A single process-wide pool lives in bridge.cpp (static HandlePool g_pool). ctypes has no pool-handle parameter; one library, one pool. A future extension could pass pool ids if multiple pools are needed.
9.4 C ABI Bridge: extern “C” Without Python.h
Full source: bridge.cpp, c_types.h
ctypes requires C-linkable symbols. The bridge file wraps C++ in extern "C" blocks. C++ classes, templates, and name-mangled symbols never cross the boundary.
Shared pool API:
void handle_release(int64_t handle);
int handle_type(int64_t handle); /* TypeId or -1 */
const char* handle_last_error(void);
Per-type APIs follow a consistent naming pattern: config_create, config_process, counter_create, counter_increment, …
Error model: ctypes cannot raise Python exceptions from C without Python.h. The bridge uses:
thread_local std::string g_last_error+handle_last_error()- Sentinel returns:
0for invalid handle,-1for failedintstatus
Python checks return values and raises RuntimeError:
def _check_handle(handle: int) -> int:
if handle == 0:
raise RuntimeError(_last_error())
return handle
C structs shared by C and ctypes live in c_types.h with extern "C" guards — plain C layout, no C++ features:
typedef struct ConfigSpec {
int32_t timeout;
int32_t enable_ssl;
char server_url[HANDLE_BRIDGE_URL_MAX];
} ConfigSpec;
9.5 Python Layer: CDLL, HandleResource, Typed Wrappers
Full sources: _native.py, handle.py, config.py
Load the library (path relative to package):
_LIB_PATH = Path(__file__).resolve().parent.parent / "libhandle_bridge.so"
_lib = ctypes.CDLL(str(_LIB_PATH))
Register every symbol (function prototypes):
_lib.config_create.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
_lib.config_create.restype = ctypes.c_int64
HandleResource is the base class every wrapper inherits:
class HandleResource:
def __init__(self, handle: int) -> None:
if handle == 0:
raise ValueError("invalid handle")
self._handle = handle
self._closed = False
def close(self) -> None:
if self._closed or self._handle == 0:
return
_lib.handle_release(self._handle)
self._handle = 0
self._closed = True
def __enter__(self): return self
def __exit__(self, *args): self.close()
def __del__(self): self.close()
Typed wrappers (Config, Counter, …) expose classmethod create(...) factories that call the C create function, check the handle, and return cls(handle). Methods pass self.handle into C operation functions.

9.6 Complex Types as Input (Accept Structs)
Part IV focused on passing structs into C. The handle-pool demo uses the same ctypes machinery for creation and mutation APIs.
| C API | Role |
|---|---|
config_create_from_spec(const ConfigSpec*) |
Create Config from struct input |
counter_apply_delta(int64_t, const DeltaSpec*) |
Apply repeated delta from struct |
config_spec_merge(left, right, out) |
Pure C helper — no handle, struct in/out |
Python mirrors C structs in types.py:
class ConfigSpec(ctypes.Structure):
_fields_ = [
("timeout", ctypes.c_int32),
("enable_ssl", ctypes.c_int32),
("server_url", ctypes.c_char * HANDLE_BRIDGE_URL_MAX),
]
Factory usage:
spec = make_config_spec(30, "http://server.com", True)
config = Config.create_from_spec(spec)
Pass with byref:
_lib.config_create_from_spec.argtypes = [ctypes.POINTER(ConfigSpec)]
handle = _lib.config_create_from_spec(ctypes.byref(spec))
Embedded char[] fields avoid the keepalive problems of char* in Part IV §8.5 — the struct owns the bytes. Pointer-string patterns from Part IV still apply if you add char* fields later.
9.7 Complex Types as Output — Out-Parameter and Return-by-Value
Two patterns that do not add a new pool entry:
Out-parameter (caller allocates struct, C fills)
int config_get_snapshot(int64_t handle, ConfigSnapshot* out);
Python allocates ConfigSnapshot() on the stack, passes ctypes.byref(out), reads fields into a view dataclass:
def snapshot(self) -> ConfigSnapshotView:
out = ConfigSnapshot()
_check_status(_lib.config_get_snapshot(self.handle, ctypes.byref(out)))
return snapshot_to_view(out)
Lifetime: the output exists only in Python for that call. No handle is returned.
Return-by-value (C returns struct copy)
ProcessSummary config_process_summary(int64_t handle);
ctypes copies the struct across the FFI boundary when restype is set:
_lib.config_process_summary.argtypes = [ctypes.c_int64]
_lib.config_process_summary.restype = ProcessSummary
summary = _lib.config_process_summary(self.handle)
This works for small POD structs. Large or heap-backed results are a poor fit — every read pays a full copy, and pointer fields inside the struct reintroduce Part IV ownership issues.
9.8 Complex Types as Output — Handle-Return Pattern
The third output pattern stores the result as a new HandleObject in the pool and returns its integer handle. Python constructs a dedicated wrapper class from that handle.

| Out-parameter | Return-by-value | Handle-return | |
|---|---|---|---|
| C returns | int status; fills *out |
ProcessSummary struct |
int64_t new handle |
| Python gets | dataclass view | dataclass view | ProcessSummaryResource(handle) |
| C++ lifetime | no pool entry | copy at FFI boundary | pool owns until handle_release |
| Best for | small snapshots | small POD reads | results you query repeatedly; C++-backed state |
9.9 Type Safety: TypeId and get_as
Every operation that dereferences a handle must validate existence and type. Wrong-type handles are a common FFI bug (passing a Config handle where Counter is expected).
HandlePool::get_as centralizes the check:
template <typename T>
T* get_as(HandleId handle, TypeId expected) {
HandleObject* obj = get(handle);
if (obj == nullptr || obj->type() != expected) {
return nullptr;
}
return static_cast<T*>(obj);
}
Each concrete class implements type():
TypeId Config::type() const { return TypeId::Config; }
Bridge functions use one error path:
Config* config = g_pool.get_as<Config>(handle, TypeId::Config);
if (config == nullptr) {
set_error("invalid handle or wrong type");
return -1;
}
Three failure modes, same message:
- Invalid handle — never existed, or already
handle_released. - Wrong
TypeId— e.g.counter_increment(config_handle, 1)whenconfig_handlepoints at aConfig. - Null out-pointer on read APIs —
config_get_snapshot(handle, nullptr).
The test proves cross-type misuse fails:
config = Config.create(30, "http://server.com", True)
result = _lib.counter_increment(config.handle, 1)
assert result == -1
assert b"invalid handle or wrong type" in _lib.handle_last_error()
Design notes:
- No
dynamic_cast/ RTTI — the C ABI does not carry C++ type information;TypeIdis the explicit contract. static_castaftertype()check is safe because only the pool creates objects and each class reports a fixedTypeId.- Python can query
handle_type(handle)orwrapper.type_idfor debugging; wrappers should still rely on typed C APIs, not blind casts.
9.10 Extending the Framework
Adding a new C++ type (e.g. Buffer) without changing HandlePool or HandleResource:
- Add
TypeId::Bufferintypes.hpp. - Implement
class Buffer : public HandleObjectin newbuffer.hpp/buffer.cpp. - Add
buffer_create/buffer_*functions inbridge.cpp(or split per-type bridge files as the project grows). - Append sources to
MakefileSOURCES. - Register
argtypes/restypein_native.py. - Add
Buffer(HandleResource)in newbuffer.py; export from__init__.py.
For complex results, add a BufferViewResource : HandleObject and buffer_create_view(source_handle) returning a new handle — same handle-return pattern as §9.8.
9.11 Comparison Across the Series
Part I PyCapsule |
Part IV ctypes.Structure |
Part V handle pool | |
|---|---|---|---|
| Token | capsule object | Structure instance |
int64_t handle |
| Owner | capsule destructor | Python GC + keepalive | HandlePool + handle_release |
| Language | C struct in C extension | plain C .so |
C++ classes + virtual interface |
| Python glue | Python.h / PyInit_* |
ctypes only | ctypes only |
| Multi-type dispatch | capsule name string | N/A (layout only) | TypeId + get_as |
| Complex input | PyArg_ParseTuple |
Structure + byref |
Structure + byref (same as IV) |
| Complex output | return Python objects from C API | out-param / return-by-value copy | out-param, by-value, or handle-return |
| User API | module functions | InputRecordPy, transform() |
Config, Counter, *Resource |
When to use Part V:
- Existing or new C++ codebase behind a stable C ABI.
- You want ctypes-only Python integration (no per-library
PyInit_*). - Multiple C++ types share one
.soand need typed handle dispatch. - Complex results should live as pool-managed objects with explicit
close()/ context managers.
When to prefer Part I instead: tight integration with Python’s object model (PyTypeObject, attributes, exceptions as first-class types).
When to prefer Part IV alone: plain C library, transient struct marshalling, no long-lived C++ objects.
References
- Part I — Overview — §2.1
PyCapsuleopaque handle - Part II — Execution
- Part III — ctypes and CFFI — §7.2 scalar ctypes, libffi
- Part IV — Complex ctypes Structs and Handles — §8 struct mirroring, §8.11 handle concept
- ctypes — Python 3 documentation
- ctypes — Structures and unions
- ctypes — Function prototypes
- ctypes —
byref - Handle (computing)
- Demo: c_ext_handle_binding
- Capsule contrast: c_ext_capsule_config