| name | cpp-bindings |
| description | Use when writing Python bindings for C++ HEP code with nanobind or pybind11: exposing a C++ class or function to Python, binding STL containers (vector, map), handling numpy/awkward array interop, choosing between nanobind and pybind11, or setting up a CMake build that produces a Python extension module. |
C++ Python Bindings (nanobind / pybind11)
Overview
nanobind and pybind11 are C++ header-only libraries for creating Python
extension modules from C++ code. pybind11 is the established standard; nanobind
is a leaner, faster successor from the same author (Wenzel Jakob), offering
smaller binary size and faster compile times. (Current nanobind requires Python
3.9+; older releases supported 3.8.) In HEP, bindings are used to expose
ROOT-based code, custom reconstruction algorithms, or legacy C++ analysis code
to Python without rewriting.
When to Use
- Exposing a C++ class or function to Python without rewriting it
- Binding STL containers (
std::vector, std::map) or Eigen arrays to numpy
- Wrapping ROOT-based reconstruction algorithms or legacy C++ analysis code
- Choosing between nanobind (faster compile, leaner) and pybind11 (broader
ecosystem) for a new project
- Setting up a CMake build that produces an importable Python extension module
Key Concepts
| Feature | nanobind | pybind11 |
|---|
| Compile time | Faster (30–50%) | Slower |
| Binary size | Smaller | Larger |
| STL casters | Opt-in per-type header | Bulk stl.h header |
| ABI isolation | Versioned ABI + domain isolation | No version isolation |
| Maturity | Newer (2022+) | Very mature (2015+) |
| Documentation | Growing | Comprehensive |
Recommendation: Use nanobind for new projects. Use pybind11 when
integrating with existing pybind11-based code or when you need the larger
ecosystem of pybind11 extensions.
Canonical Patterns
CMake setup (nanobind)
cmake_minimum_required(VERSION 3.15)
project(myhep)
find_package(Python 3.9 COMPONENTS Interpreter Development.Module REQUIRED)
find_package(nanobind CONFIG REQUIRED)
nanobind_add_module(myhep_ext myhep.cpp)
target_link_libraries(myhep_ext PRIVATE MyHEPLib)
CMake setup (pybind11)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(myhep_ext myhep.cpp)
target_link_libraries(myhep_ext PRIVATE MyHEPLib)
Bind a simple function (nanobind)
#include <nanobind/nanobind.h>
namespace nb = nanobind;
double invariant_mass(double px, double py, double pz, double e) {
double m2 = e*e - px*px - py*py - pz*pz;
return m2 > 0.0 ? std::sqrt(m2) : 0.0;
}
NB_MODULE(myhep_ext, m) {
m.def("invariant_mass", &invariant_mass,
nb::arg("px"), nb::arg("py"), nb::arg("pz"), nb::arg("e"),
"Compute invariant mass from four-momentum (GeV).");
}
import myhep_ext
m = myhep_ext.invariant_mass(0.0, 0.0, 0.0, 125.0)
Bind a class (nanobind)
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
namespace nb = nanobind;
class Jet {
public:
Jet(double pt, double eta, double phi) : pt_(pt), eta_(eta), phi_(phi) {}
double pt() const { return pt_; }
double eta() const { return eta_; }
std::string repr() const {
return "Jet(pt=" + std::to_string(pt_) + ")";
}
private:
double pt_, eta_, phi_;
};
NB_MODULE(myhep_ext, m) {
nb::class_<Jet>(m, "Jet")
.def(nb::init<double, double, double>(),
nb::arg("pt"), nb::arg("eta"), nb::arg("phi"))
.def("pt", &Jet::pt)
.def("eta", &Jet::eta)
.def("__repr__", &Jet::repr);
}
numpy array interop (nanobind)
#include <nanobind/nanobind.h>
#include <nanobind/ndarray.h>
namespace nb = nanobind;
using Array1D = nb::ndarray<double, nb::shape<-1>, nb::c_contig>;
nb::ndarray<double, nb::ndim<1>, nb::c_contig>
compute_pt(Array1D px, Array1D py) {
size_t n = px.shape(0);
auto result = new double[n];
for (size_t i = 0; i < n; ++i)
result[i] = std::sqrt(px(i)*px(i) + py(i)*py(i));
nb::capsule owner(result, [](void *p) noexcept { delete[] (double*)p; });
return nb::ndarray<double, nb::ndim<1>, nb::c_contig>(result, {n}, owner);
}
NB_MODULE(myhep_ext, m) {
m.def("compute_pt", &compute_pt);
}
STL containers (pybind11)
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
namespace py = pybind11;
std::vector<double> jet_pts(std::vector<double> px, std::vector<double> py) {
std::vector<double> result;
for (size_t i = 0; i < px.size(); ++i)
result.push_back(std::sqrt(px[i]*px[i] + py[i]*py[i]));
return result;
}
PYBIND11_MODULE(myhep_ext, m) {
m.def("jet_pts", &jet_pts);
}
Gotchas
- GIL (Global Interpreter Lock): For CPU-bound C++ functions that don't
touch Python objects, release the GIL with
nb::gil_scoped_release to allow
parallel Python threads.
- Lifetime management: Python's garbage collector doesn't know about C++
object lifetimes. Use
nb::keep_alive or py::keep_alive policies for
objects that hold references.
- STL opaque types in nanobind: nanobind does NOT auto-convert
std::vector
to Python lists by default (unlike pybind11). Include
<nanobind/stl/vector.h> to enable conversion.
- ROOT dictionary conflicts: When linking against ROOT, ROOT's
auto-generated dictionaries may conflict with pybind11/nanobind. Use a
separate shared library to isolate ROOT from the Python extension.
- ABI mismatch: The Python extension must be compiled against the same
Python version and ABI as the interpreter. Use
scikit-build-core or
meson-python to manage this.
Interop
- numpy:
<nanobind/ndarray.h> (nanobind) or <pybind11/numpy.h>
(pybind11) for zero-copy array passing.
- awkward: awkward's
ak.from_buffers / ak.to_buffers can wrap C++
arrays; combine with nanobind ndarray for zero-copy workflows.
- CMake + scikit-build-core: The recommended build backend for Python
extensions that use CMake.
Docs
nanobind: https://nanobind.readthedocs.io/ pybind11:
https://pybind11.readthedocs.io/