Automating pybind11 Bindings with C++26 Reflection: What a Month of Real Code Reveals
Source: isocpp
Static reflection for C++, formalized in P2996 and voted into the C++26 working draft at the Kona meeting in early 2024, makes a specific promise: compile-time introspection of C++ types using ordinary values rather than type-level template gymnastics. Boris Staletić’s month-long experiment using it to automate pybind11 bindings, published on isocpp.org in November 2025, is a useful field report on where the language feature succeeds in practice and where the remaining gaps create friction.
Why pybind11 Is a Good Test Case
Generating Python bindings for C++ structs is one of the canonical examples of mechanical repetition in C++ codebases. A typical registration looks like this:
PYBIND11_MODULE(example, m) {
py::class_<Vec3>(m, "Vec3")
.def_readwrite("x", &Vec3::x)
.def_readwrite("y", &Vec3::y)
.def_readwrite("z", &Vec3::z)
.def("dot", &Vec3::dot)
.def("normalize", &Vec3::normalize);
}
Every field and method appears twice: once in the struct definition, and again in the registration block. The two have no compile-time connection. A field renamed in the struct silently breaks the binding unless the registration is updated by hand, and macro solutions handle only the simplest cases.
With P2996, the goal is a generic template that does the registration from reflection:
template <typename T>
void bind_class(py::module_& m) {
auto cls = py::class_<T>(m, std::meta::identifier_of(^T).data());
template for (constexpr auto mem : std::meta::nonstatic_data_members_of(^T)) {
cls.def_readwrite(
std::meta::identifier_of(mem).data(),
&T::[: mem :]
);
}
}
The ^T operator produces a std::meta::info value representing the type. std::meta::nonstatic_data_members_of returns a compile-time range of info values, one per field. std::meta::identifier_of extracts the name as a string_view. The [: mem :] splicer turns the reflected member back into a pointer-to-member expression. The template for loop is from P1306, a companion expansion statement proposal. Together, they eliminate the registration boilerplate entirely.
The Value-Based Design and Why It Matters
The foundational decision in P2996 is that reflections are values, not types. Previous C++ metaprogramming approaches, including Boost.Hana and conventional std::tuple-based machinery, encoded metadata as types. A list of struct members became a tuple<type<A>, type<B>, type<C>>. Iteration required recursive template specializations. Compile errors produced multi-level instantiation stacks that reported problems in terms of internal machinery rather than user code.
P2996’s std::meta::info is a first-class value. It can be stored in a constexpr variable, placed in a std::vector<std::meta::info> at compile time, filtered with std::ranges algorithms, and operated on with ordinary consteval functions. Filtering members down to public ones looks like this:
auto public_members = std::meta::nonstatic_data_members_of(^T)
| std::views::filter(std::meta::is_public);
This is ordinary C++ that happens to run at compile time. Compile errors refer to real functions and real types rather than internal template instantiations. Libraries like magic_enum, which extract enum names by parsing __PRETTY_FUNCTION__ output with hard-coded value-range limits and compiler-specific hacks, become unnecessary. std::meta::enumerators_of(^E) is the portable replacement for any enum regardless of value range.
The prior art here is D’s compile-time reflection. D has had __traits(allMembers, T) and static foreach for years, and P2996’s design draws clearly from what worked there. The practical difference is that D’s traits are a special built-in keyword syntax, while P2996 delivers the same capability through a namespace of ordinary consteval functions, which integrates more naturally with the rest of the language tooling.
Where the Real Friction Lives
The binding generator works, but several friction points emerge as the use case moves past basic field enumeration.
P1306 is a separate proposal. Expansion statements are not merged with P2996 in the C++26 draft. Without template for, iterating over a compile-time range of reflected members falls back to index-sequence boilerplate:
constexpr auto members = std::meta::nonstatic_data_members_of(^T);
[&]<std::size_t... I>(std::index_sequence<I...>) {
((cls.def_readwrite(
std::meta::identifier_of(members[I]).data(),
&T::[: members[I] :]
)), ...);
}(std::make_index_sequence<members.size()>());
That is the same pattern C++ codebases have required since C++14. The expressive improvement from reflection is real, but it is partially obscured behind boilerplate that P1306 was designed to eliminate. Without expansion statements, the ergonomic case for P2996 over heavy TMP is narrower than the proposal papers suggest.
Overloaded functions require manual disambiguation. pybind11 handles overloads via py::overload_cast<ArgTypes...>. P2996 can enumerate overloads through std::meta::members_of, but selecting the right overload for a given signature requires reconstructing argument types from reflected parameter information. The binding generator needs a different code path for overloaded versus non-overloaded methods, which breaks the uniformity that made the template attractive in the first place.
Default argument values are not in the reflection model. pybind11 supports py::arg("name") = default_value for cleaner Python calling conventions. P2996 can detect that a parameter has a default but cannot expose the value. Users who want default-aware bindings still need manual annotation or a separate specification mechanism.
User-defined attribute reflection is not standardized. The natural customization point for binding generation would be member annotations such as [[py::name("renamed")]] or [[py::skip]]. P2996 includes std::meta::attributes_of, but user-defined attributes are not standardized in the current C++26 draft. The workaround involves marker tag types, which is functional but less clear than direct annotation.
How Other Languages Approach This Problem
Rust’s approach is procedural macros, used most visibly in serde’s #[derive(Serialize, Deserialize)]. Proc macros operate on token streams rather than semantic models. They see syntax and produce syntax. The result is powerful and widely adopted, but error messages refer to the token stream rather than the semantic entities, and the requirement for a separate crate adds build infrastructure overhead that P2996 avoids entirely.
Java and Python offer runtime reflection, which is a different trade. java.lang.reflect and Python’s inspect module carry runtime overhead and provide no compile-time guarantees. For binding generators that run at program startup the cost is amortized, but the absence of type-level safety remains a fundamental distinction from P2996’s compile-time model.
D’s model is still the closest comparison. __traits(allMembers, T) is semantically equivalent to std::meta::members_of(^T), and static foreach is exactly what P1306 proposes. D shipped both features together, which is why D reflection code does not have the P1306-shaped gap that C++26 reflection currently has. That sequencing decision made D’s reflection ergonomics better from day one.
What the Missing Pieces Are
The experiment points to a small set of features that would close the remaining friction.
P1306 expansion statements are the highest-priority gap. Without template for, the iteration model requires index sequences that undercut the value-based design’s ergonomic advantage. The two proposals are effectively one feature, and shipping them on different schedules creates an incomplete story for everyone who reaches for reflection expecting clean iteration.
Code injection, currently under P3157 and related papers, would allow programmatically adding members to a class. This enables a pattern where the struct definition is the complete specification for binding generation, with no registration step. The struct and its Python interface would be a single source of truth.
Standardized user-defined attributes would allow annotating individual members directly with binding customizations, eliminating the need for tag-type workarounds and bringing C++ closer to what Rust achieves with derive macro attributes.
The Shape of the Feature
P2996 delivers on the core promise. Enum-to-string, struct serialization, visitor dispatch, and layout inspection all have clean native solutions without macros or compiler hacks. The pybind11 binding generator produces correct output. The friction points are real but bounded, and they trace to a small number of adjacent proposals that are actively in progress.
Experimental P2996 support is available on Compiler Explorer via the clang (experimental reflection) toolchain, maintained in the Bloomberg clang fork. If you write C++ that operates on struct members or enum values, the experiment is worth running before C++26 ships. The feature is close enough to its final form that the investment in learning it now will carry forward.