Table of Contents

  1. pybind11 Overview
  2. FetchContent vs find_package
  3. pybind11_add_module()
  4. Binding Classes and Functions
  5. NumPy Integration
  6. scikit-build-core for Distribution
  7. Stub Generation
  8. Debugging Extensions
Back to CMake Mastery Series

pybind11

June 4, 2026 Wasil Zafar 10 min read

The definitive guide to creating Python bindings for C++ code with pybind11 and CMake — module creation, NumPy interoperability, packaging with scikit-build-core, and debugging compiled extensions.

Bindings

pybind11 Overview

pybind11 is a lightweight header-only library that exposes C++ types in Python and vice versa, primarily used to create Python extension modules from existing C++ code. It leverages C++11 features (variadic templates, move semantics) to provide a clean binding syntax without the boilerplate of the raw Python C API or older tools like Boost.Python.

From a CMake perspective, pybind11 provides the pybind11_add_module() function — a specialized wrapper around add_library(MODULE ...) that automatically handles Python include paths, library linking, extension suffixes, and platform-specific flags.

Key Insight: pybind11 modules are shared libraries with Python-specific naming conventions (e.g., mymodule.cpython-311-x86_64-linux-gnu.so). The pybind11_add_module() function handles all platform-specific suffix logic automatically.

FetchContent vs find_package

pybind11 supports both acquisition methods. FetchContent is preferred for reproducibility, while find_package works when pybind11 is installed via pip:

# Option A: FetchContent (recommended for reproducibility)
cmake_minimum_required(VERSION 3.20)
project(mymodule LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(FetchContent)
FetchContent_Declare(
    pybind11
    GIT_REPOSITORY https://github.com/pybind/pybind11.git
    GIT_TAG        v2.12.0
    GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(pybind11)

pybind11_add_module(mymodule src/bindings.cpp)
target_link_libraries(mymodule PRIVATE mylib)
# Option B: find_package (after pip install pybind11)
cmake_minimum_required(VERSION 3.20)
project(mymodule LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

# pybind11 installed via: pip install pybind11
find_package(pybind11 2.12 REQUIRED)

pybind11_add_module(mymodule src/bindings.cpp)
target_link_libraries(mymodule PRIVATE mylib)
# Install pybind11 for find_package discovery
pip install pybind11

# Configure with the correct Python
cmake -B build -S . \
    -Dpybind11_DIR=$(python -c "import pybind11; print(pybind11.get_cmake_dir())")

# Or set Python explicitly
cmake -B build -S . \
    -DPython_EXECUTABLE=$(which python3) \
    -DPYTHON_EXECUTABLE=$(which python3)

pybind11_add_module()

The pybind11_add_module() function is the primary CMake integration point. It creates a MODULE library target with all necessary Python configuration:

# Basic module definition
pybind11_add_module(mymodule
    src/bindings.cpp
    src/math_bindings.cpp
    src/io_bindings.cpp
)

# Link against your C++ library
target_link_libraries(mymodule PRIVATE
    mylib          # Your C++ library
    Eigen3::Eigen  # Other dependencies
)

# Set output directory for development (importable from build dir)
set_target_properties(mymodule PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/mypackage
)

# Install the module
install(TARGETS mymodule
    LIBRARY DESTINATION ${Python_SITEARCH}/mypackage
)
# Advanced: multiple modules in one project
pybind11_add_module(_core src/core_bindings.cpp)
pybind11_add_module(_math src/math_bindings.cpp)
pybind11_add_module(_io src/io_bindings.cpp)

foreach(mod _core _math _io)
    target_link_libraries(${mod} PRIVATE mylib)
    set_target_properties(${mod} PROPERTIES
        LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/mypackage
    )
endforeach()

Binding Classes and Functions

The C++ binding code uses pybind11 macros and helper classes to expose your API to Python:

// src/bindings.cpp — Complete binding example
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>          // std::vector, std::map auto-conversion
#include <pybind11/operators.h>    // operator overloading
#include "matrix.h"
#include "solver.h"

namespace py = pybind11;

// Bind a simple function
double compute_norm(const std::vector<double>& vec) {
    double sum = 0.0;
    for (double v : vec) sum += v * v;
    return std::sqrt(sum);
}

PYBIND11_MODULE(mymodule, m) {
    m.doc() = "My high-performance math module";

    // Free functions
    m.def("compute_norm", &compute_norm,
          py::arg("vec"),
          "Compute the L2 norm of a vector");

    // Class binding
    py::class_<Matrix>(m, "Matrix")
        .def(py::init<int, int>(),
             py::arg("rows"), py::arg("cols"))
        .def("rows", &Matrix::rows)
        .def("cols", &Matrix::cols)
        .def("get", &Matrix::get,
             py::arg("i"), py::arg("j"))
        .def("set", &Matrix::set,
             py::arg("i"), py::arg("j"), py::arg("value"))
        .def("transpose", &Matrix::transpose)
        .def("__repr__", [](const Matrix& m) {
            return "<Matrix " + std::to_string(m.rows()) +
                   "x" + std::to_string(m.cols()) + ">";
        })
        // Operator overloading
        .def(py::self + py::self)
        .def(py::self * float());

    // Enum binding
    py::enum_<Solver::Method>(m, "SolverMethod")
        .value("LU", Solver::Method::LU)
        .value("QR", Solver::Method::QR)
        .value("SVD", Solver::Method::SVD)
        .export_values();

    // Class with enum parameter
    py::class_<Solver>(m, "Solver")
        .def(py::init<Solver::Method>(),
             py::arg("method") = Solver::Method::LU)
        .def("solve", &Solver::solve,
             py::arg("A"), py::arg("b"));
}

NumPy Integration

pybind11's NumPy support enables zero-copy data exchange between C++ and Python arrays, critical for scientific computing performance:

// src/numpy_bindings.cpp — NumPy array integration
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <cmath>

namespace py = pybind11;

// Accept and return NumPy arrays (zero-copy when possible)
py::array_t<double> multiply_elementwise(
    py::array_t<double> input1,
    py::array_t<double> input2)
{
    auto buf1 = input1.request();
    auto buf2 = input2.request();

    if (buf1.size != buf2.size)
        throw std::runtime_error("Input shapes must match");

    auto result = py::array_t<double>(buf1.size);
    auto buf_result = result.request();

    double* ptr1 = static_cast<double*>(buf1.ptr);
    double* ptr2 = static_cast<double*>(buf2.ptr);
    double* ptr_result = static_cast<double*>(buf_result.ptr);

    for (ssize_t i = 0; i < buf1.size; i++) {
        ptr_result[i] = ptr1[i] * ptr2[i];
    }

    return result;
}

// Unchecked access for maximum performance (no bounds checking)
py::array_t<double> fast_normalize(py::array_t<double, py::array::c_style> input) {
    auto r = input.unchecked<1>();  // 1D array, no bounds checks
    auto result = py::array_t<double>(r.size());
    auto w = result.mutable_unchecked<1>();

    double norm = 0.0;
    for (ssize_t i = 0; i < r.size(); i++)
        norm += r(i) * r(i);
    norm = std::sqrt(norm);

    for (ssize_t i = 0; i < r.size(); i++)
        w(i) = r(i) / norm;

    return result;
}

PYBIND11_MODULE(_numpy_ops, m) {
    m.def("multiply_elementwise", &multiply_elementwise,
          py::arg("a"), py::arg("b"),
          "Element-wise multiplication of two arrays");
    m.def("fast_normalize", &fast_normalize,
          py::arg("input"),
          "Normalize a vector (L2 norm)");
}
# CMakeLists.txt — NumPy-aware module
find_package(Python 3.9 REQUIRED COMPONENTS Interpreter Development NumPy)

pybind11_add_module(_numpy_ops src/numpy_bindings.cpp)
target_link_libraries(_numpy_ops PRIVATE Python::NumPy)

scikit-build-core for Distribution

scikit-build-core is the modern standard for packaging CMake-based Python extensions. It replaces the legacy setup.py approach with a pyproject.toml-driven workflow:

# pyproject.toml — scikit-build-core configuration
[build-system]
requires = ["scikit-build-core>=0.8", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"

[project]
name = "mypackage"
version = "1.0.0"
requires-python = ">=3.9"
dependencies = ["numpy>=1.21"]

[tool.scikit-build]
cmake.minimum-version = "3.20"
cmake.build-type = "Release"
wheel.packages = ["src/mypackage"]

[tool.scikit-build.cmake.define]
CMAKE_CXX_STANDARD = "17"
# CMakeLists.txt — scikit-build-core compatible
cmake_minimum_required(VERSION 3.20)
project(mypackage LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

find_package(pybind11 CONFIG REQUIRED)
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module NumPy)

pybind11_add_module(_core src/bindings.cpp)
target_link_libraries(_core PRIVATE mylib)

install(TARGETS _core DESTINATION mypackage)
# Build and install the package
pip install -e .          # Editable install for development
pip install .             # Standard install
pip wheel .               # Build wheel for distribution
python -m build           # Build sdist + wheel
Best Practice: Use scikit-build-core instead of the legacy scikit-build or raw setup.py. It provides better isolation, faster builds, and correct handling of cross-compilation and editable installs.

Stub Generation

Type stubs (.pyi files) provide IDE autocompletion and type checking for compiled extensions. pybind11-stubgen generates these automatically:

# Install stub generator
pip install pybind11-stubgen

# Generate stubs after building your module
pybind11-stubgen mypackage._core -o src/

# Or integrate into CMake as a post-build step
# CMakeLists.txt — Automatic stub generation
find_package(Python REQUIRED COMPONENTS Interpreter)

pybind11_add_module(_core src/bindings.cpp)

# Generate stubs after building
add_custom_command(TARGET _core POST_BUILD
    COMMAND ${Python_EXECUTABLE} -m pybind11_stubgen
            mypackage._core
            --output-dir ${CMAKE_SOURCE_DIR}/src
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Generating type stubs for _core"
    VERBATIM
)
# Generated stub: src/mypackage/_core.pyi
from typing import List, overload
import numpy as np
import numpy.typing as npt

def compute_norm(vec: List[float]) -> float: ...
def fast_normalize(input: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: ...

class Matrix:
    def __init__(self, rows: int, cols: int) -> None: ...
    def rows(self) -> int: ...
    def cols(self) -> int: ...
    def get(self, i: int, j: int) -> float: ...
    def set(self, i: int, j: int, value: float) -> None: ...
    def transpose(self) -> "Matrix": ...
    @overload
    def __add__(self, other: "Matrix") -> "Matrix": ...
    @overload
    def __mul__(self, scalar: float) -> "Matrix": ...

Debugging Extensions

Debugging compiled Python extensions requires building with debug symbols and attaching a native debugger to the Python process:

# Debug build configuration
cmake_minimum_required(VERSION 3.20)
project(mymodule LANGUAGES CXX)

# Force debug info even in Release builds
if(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo" OR CMAKE_BUILD_TYPE STREQUAL "Debug")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0")
endif()

find_package(pybind11 REQUIRED)

pybind11_add_module(_core src/bindings.cpp)

# For Debug builds, disable optimizations for easier stepping
target_compile_options(_core PRIVATE
    $<$<CONFIG:Debug>:-O0 -g -UNDEBUG>
)
# Build with debug symbols
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
cmake --build build

# Method 1: Attach GDB to Python
gdb -ex run --args python -c "import mypackage; mypackage.compute_norm([1,2,3])"

# Method 2: Start Python under GDB
gdb python
(gdb) run -c "import mypackage; mypackage.compute_norm([1,2,3])"
(gdb) break bindings.cpp:42
(gdb) continue

# Method 3: VS Code launch.json for mixed Python/C++ debugging
# Use "Python C++ Debugger" extension
Pitfall: If you build the extension with one Python version but import it with another, you'll get cryptic segfaults or import errors. Always verify with python -c "import sys; print(sys.executable)" that the same Python is used for both building and running.