The pybind11 binding file is a second definition of your API. Every class you expose to Python appears twice: once as C++ and once as .def() chains in a module registration block. If you rename a method in C++, the binding does not fail to compile. It silently stops working from the Python side, or worse, continues to work but calls something that no longer exists.
Richard Hickling’s article on isocpp.org frames this through the lens of algorithmic trading, where Python handles strategy development and C++ handles execution. That framing is useful because it makes the maintenance cost concrete: firms run multiple pricers, multiple teams cycle through them, and the binding code accumulates as a parallel codebase that nobody owns.
But the problem is not specific to trading. It shows up wherever Python and C++ share a codebase: scientific simulation, game engines, computer vision, numerical libraries. pybind11 is ubiquitous in these stacks, and so is the maintenance debt that comes with it.
The Synchronization Problem
When you write pybind11 bindings manually, you are not just writing glue code. You are maintaining a synchronization invariant between two separate representations of the same interface. The C++ class is the ground truth, and the binding file is a subscriber that must be kept up to date by hand.
// The ground truth
class BlackScholesPricer {
public:
double price(double spot, double strike, double vol, double rate, double expiry);
double delta(double spot, double strike, double vol, double rate, double expiry);
double vega(double spot, double strike, double vol, double rate, double expiry);
void set_dividend_yield(double q);
double get_dividend_yield() const;
};
// The manually-maintained subscriber
PYBIND11_MODULE(pricers, m) {
py::class_<BlackScholesPricer>(m, "BlackScholesPricer")
.def("price", &BlackScholesPricer::price)
.def("delta", &BlackScholesPricer::delta)
.def("vega", &BlackScholesPricer::vega)
.def("set_dividend_yield", &BlackScholesPricer::set_dividend_yield)
.def("get_dividend_yield", &BlackScholesPricer::get_dividend_yield);
}
The invariant holds as long as developers remember to update the subscriber when the ground truth changes. That is a weak invariant. It breaks on refactoring, on code review that approves a rename without checking the binding file, on the engineer who joined last month and did not know the binding file existed.
External tools like Binder address this by sitting outside the compiler and walking the Clang AST to emit pybind11 code automatically. Binder is used by projects like Rosetta Commons, where the binding surface is large enough that manual maintenance is not viable. But external tools carry their own friction. They are separate build steps, they require explicit configuration for template instantiations, and they consume compiler output rather than participating in compilation. The invariant is still being maintained, just by a tool rather than a human.
P2996 and the Value-Based Reflect Operator
P2996, accepted into the C++26 working draft at the Wrocław WG21 meeting in November 2024, takes a different approach. It moves type introspection into the compiler, making member names and type information available as first-class constant values.
The core mechanism is the reflect operator ^, which produces a std::meta::info value from any C++ entity:
constexpr std::meta::info r = ^BlackScholesPricer; // reflects the class
constexpr std::meta::info f = ^int; // reflects a type
std::meta::info is a scalar, opaque literal type. It carries no overhead at runtime because all reflection queries are consteval, evaluated entirely at compile time and leaving no trace in the generated binary.
Query functions give structured access to the reflected entity:
std::meta::nonstatic_data_members_of(^T) // returns vector<info>
std::meta::members_of(^T) // all members
std::meta::identifier_of(member) // name as string_view
std::meta::is_public(member) // access specifier query
std::meta::type_of(member) // reflected type of a member
The companion splice operator [::] converts a std::meta::info back into a usable C++ construct, adapting to context:
using T = [: some_type_reflection :]; // type splice
auto val = obj.[: member_reflection :]; // member access splice
The expansion statement (template for, from P1306) provides iteration over compile-time sequences:
template for (constexpr auto member : std::meta::nonstatic_data_members_of(^T)) {
// body instantiated once per member
}
These three pieces together, the reflect operator, the query functions, and expansion statements, are what make automatic binding generation possible inside the compiler rather than around it.
The Binding Loop in Practice
With P2996, the pybind11 registration for a class becomes a function template:
template <typename T>
void auto_bind(py::module_& m, std::string_view name) {
auto cls = py::class_<T>(m, name.data());
template for (constexpr auto field : std::meta::nonstatic_data_members_of(^T)) {
if constexpr (std::meta::is_public(field)) {
cls.def_readwrite(
std::meta::identifier_of(field).data(),
&T::[: field :]
);
}
}
template for (constexpr auto method : std::meta::members_of(^T)) {
if constexpr (std::meta::is_public(method)
&& std::meta::is_nonstatic_member_function(method)
&& !std::meta::is_special_member(method)) {
cls.def(
std::meta::identifier_of(method).data(),
&T::[: method :]
);
}
}
}
Then in the module definition:
PYBIND11_MODULE(pricers, m) {
auto_bind<BlackScholesPricer>(m, "BlackScholesPricer");
}
When BlackScholesPricer::vega gets renamed to greek_vega, the binding updates automatically on the next build. No separate tool run, no configuration update, no developer remembering to check the binding file.
Enum bindings follow the same pattern. Instead of listing every enumerator by hand:
py::enum_<OptionType>(m, "OptionType")
.value("Call", OptionType::Call)
.value("Put", OptionType::Put);
You write one template:
template <typename E> requires std::is_enum_v<E>
void bind_enum(py::module_& m) {
auto e = py::enum_<E>(m, std::meta::identifier_of(^E).data());
template for (constexpr auto v : std::meta::enumerators_of(^E)) {
e.value(std::meta::identifier_of(v).data(), [:v:]);
}
}
Add an enumerator to OptionType and Python sees it immediately, because the next compilation regenerates the binding.
The Failure Mode Shift
The more precise way to understand what reflection does here is in terms of failure modes. Manual binding maintenance fails when a developer forgets to update the binding file after changing C++. That failure is invisible until runtime, when Python calls a method that no longer exists or calls the wrong method because names were reshuffled.
Reflection-based binding fails when the C++ code does not compile, because the binding template and the class definition are part of the same compilation unit. If the class changes in a way that invalidates the generated binding (say, a previously public method becomes private), the build fails. The failure is immediate and caught in CI.
Hickling’s article gestures toward this without naming it directly. The binding problem does not disappear, but it reduces to a compilation problem, and compilation is something you can enforce reliably. A runtime synchronization error is a matter of process and memory. A compile-time error is a hard gate.
What Reflection Does Not Automate
Boris Staletić’s month-long experiment with P2996-based pybind11 automation, published on isocpp.org, found an automation ceiling around 70-80% of a typical C++ class. The remaining friction comes from cases that require policy decisions reflection cannot make on its own.
Overloaded methods are the clearest case. When price has three overloads taking different argument types, the automatic binder cannot decide which one Python should see, or whether they should be unified into a single Python method with flexible arguments. pybind11’s py::overload_cast<ArgTypes...> handles disambiguation, but generating the right cast requires knowing developer intent.
Ownership semantics are equally opaque. pybind11 supports six distinct return value policies: copy, reference, reference internal, move, take ownership, and automatic. The right policy depends on the lifetime relationship between the returned object and its container. Reflection can see that a method returns a pointer, but cannot infer whether that pointer transfers ownership. This is precisely the gap that P1854, user-defined attributes, would address by letting developers annotate their C++ code with hints that an automatic binder could read.
Custom Python names are another manual case. A C++ method named get_price might be better exposed to Python as price to match Python conventions. Reflection generates names from C++ identifiers, which is correct for most cases but wrong for APIs with established Python idioms.
These limitations define where automation ends and explicit .def() calls take over. A binding file that is 80% generated and 20% explicit is still substantially better than one that is 100% manual. The maintenance burden shifts from the common case to the deliberate exception.
Compiler Support and Timeline
P2996 is in the C++26 working draft alongside P1306 (expansion statements). Both need to ship together for the binding generation pattern to work ergonomically, since template for is the iteration mechanism. The February 2025 Hagenberg meeting confirmed both.
For compiler status: Bloomberg maintains a Clang fork (clang-p2996) with core features substantially complete and accessible via Compiler Explorer. The EDG frontend has the most complete implementation. GCC has community-driven progress. MSVC has no public implementation.
For production deployment, the realistic window is 2027-2028 depending on which compiler your project requires. Teams on Linux with Clang adoption paths can experiment on the Bloomberg fork now. MSVC-dependent shops will wait longer.
What comes after C++26 is worth noting. P3294, code injection, tentatively targeting C++29, would enable metaclass-style patterns where the binding is synthesized inside the class definition rather than applied from outside. P1854, user-defined attributes, would let developers annotate methods with ownership and naming hints that automatic binders could consume. The C++26 feature set is sufficient for the core automation but leaves policy customization to later proposals.
For any codebase where Python and C++ share a public surface, the binding layer has always been a maintenance obligation that grows proportionally with the API. Reflection does not eliminate that obligation, but it converts it from ongoing manual upkeep to a one-time investment in a generation template, with the compiler enforcing correctness from that point forward.