Python is great for orchestrating and shipping logic fast, but its inner loops pay a constant tax to the CPython interpreter. When you need real throughput in a compute-heavy hot path, the answer is often to drop into C++. For a long time that meant pybind11 plus a fragile setuptools ext_modules dance. There is a better, modern path: build a C++ extension with nanobind, package it with scikit-build-core, and manage the whole project with uv.

This post walks through a complete, working example: a small package called string-utils that bridges C++ string code to Python. The full source is public on GitHub.

The toolchain

For our this post we use three tools.

uv is a Rust-powered project and dependency manager. It replaces pip, virtualenv, and pip-tools with sub-second dependency resolution and isolated, reproducible builds. One command creates the environment, one command builds the extension, one command runs the tests. You can also achieve the same results with Poetry.

scikit-build-core is the next-generation PEP 517 build backend for CMake projects. It replaces setuptools and the older scikit-build. It reads a pyproject.toml, runs your CMakeLists.txt, and produces a wheel, with clean support for editable installs.

nanobind is the binding library, written by Wenzel Jakob, the same person who created pybind11. It targets roughly 15 to 30 nanosecond invocation overhead and binaries up to about 10x smaller than pybind11, and it treats the CPython stable ABI as a first-class concern.

The current stable versions used here, as of this writing, are nanobind 2.14.0 and scikit-build-core 1.0.3. Check PyPI before you publish something pinned to a specific number.

The project layout

Keep build artifacts, C++ source, and Python wrappers cleanly separated with a hybrid src plus python structure.

TEXT
string-utils/
โ”œโ”€โ”€ CMakeLists.txt
โ”œโ”€โ”€ pyproject.toml
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ .pre-commit-config.yaml
โ”œโ”€โ”€ python/
โ”‚   โ””โ”€โ”€ string_utils/
โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚       โ”œโ”€โ”€ _core.pyi
โ”‚       โ””โ”€โ”€ py.typed
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ bindings.cpp
โ”‚   โ”œโ”€โ”€ string_ops.cpp
โ”‚   โ””โ”€โ”€ string_ops.hpp
โ””โ”€โ”€ tests/
    โ””โ”€โ”€ test_string_utils.py

The C++ lives under src/, the pure-Python wrapper and type stubs under python/string_utils/, and the tests under tests/. scikit-build-core copies python/string_utils into the wheel and installs the compiled _core module right next to it.

Configuring the build: pyproject.toml

The build-system block declares the two build-time dependencies. Note the version floors.

TOML
[build-system]
requires = ["scikit-build-core>=1.0", "nanobind>=2.14"]
build-backend = "scikit_build_core.build"

[project]
name = "string-utils"
version = "0.1.0"
requires-python = ">=3.10"

[tool.scikit-build]
minimum-version = "build-system.requires"
cmake.build-type = "Release"
wheel.packages = ["python/string_utils"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pyproject.toml

The minimum-version line tells scikit-build-core to keep its compatibility mode aligned with the floor declared in build-system.requires. That is the current recommended practice from the official docs.

Version Floors

Get the floor wrong and the build is unresolvable. A widely circulated version of this guide pinned scikit-build-core>=1.5, but the newest release on PyPI is 1.0.3. That constraint makes pip/uv give up immediately with a resolution error, which is exactly the failure this repo hit before the floor was corrected to >=1.0. Always check the actual latest release before pinning a floor.

The CMake build: what actually matters

nanobind ships first-class CMake support. The important part is the order of the find_package calls.

CMAKE
cmake_minimum_required(VERSION 3.15...3.31)
project(string_utils LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# Required BEFORE nanobind
find_package(Python COMPONENTS Interpreter Development REQUIRED)

find_package(nanobind CONFIG REQUIRED)

# Hot path as its own -O3 static library
add_library(string_ops STATIC src/string_ops.cpp)
target_include_directories(string_ops PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
set_target_properties(string_ops PROPERTIES POSITION_INDEPENDENT_CODE ON)
if(MSVC)
    target_compile_options(string_ops PRIVATE /O2)
else()
    target_compile_options(string_ops PRIVATE -O3 -Wall -Wextra -Werror)
endif()

# Bindings stay at -Os; LTO and warning suppression are nanobind-native
nanobind_add_module(_core LTO NB_SUPPRESS_WARNINGS src/bindings.cpp)
target_link_libraries(_core PRIVATE string_ops)

install(TARGETS _core DESTINATION string_utils)

Two details in this file are easy to get wrong and are worth flagging up front.

find_package Order

nanobind 2.x hard-requires the Python interpreter and development targets to be located before nanobind itself is configured. If you call find_package(nanobind CONFIG REQUIRED) first, CMake fails with a message telling you to call find_package(Python COMPONENTS Interpreter Development REQUIRED) first. It is a hard error, not a warning, so the order genuinely matters.

Optimization Split

nanobind compiles the binding module with -Os by default, and the official docs are explicit that forcing -O3 onto the bindings inflates compile time and binary size with no real benefit. The pattern this repo uses instead separates the real work from the bindings: string_ops.cpp is built as its own -O3 static library, and the thin bindings.cpp translation unit stays at nanobind's -Os default. The hot code gets aggressive optimization where it pays off, and the bindings stay lean.

The static library also needs POSITION_INDEPENDENT_CODE ON, which is a real pitfall. A static library built without position-independent code cannot be linked into a shared extension module; the linker fails with a relocation R_X86_64_32S ... can not be used when making a shared object error.

The C++ code

The actual string logic lives in src/string_ops.cpp. Keep it plain C++ with no nanobind includes, so it can be compiled independently and reused outside the extension if needed.

C++
#include <cassert>
#include <cctype>
#include <climits>
#include <sstream>

#include "string_ops.hpp"

// Note: these routines operate on individual bytes and are only fully correct
// for ASCII input. Multi-byte UTF-8 text (e.g. accented characters) is not
// case-folded or reversed as logical characters.

namespace string_utils {

std::string reverse_string(const std::string& text) {
    return std::string(text.rbegin(), text.rend());
}

bool is_palindrome(const std::string& text) {
    std::string cleaned;
    cleaned.reserve(text.size());
    for (char c : text) {
        if (!std::isspace(static_cast<unsigned char>(c))) {
            cleaned += std::tolower(static_cast<unsigned char>(c));
        }
    }
    std::string reversed(cleaned.rbegin(), cleaned.rend());
    return cleaned == reversed;
}

std::vector<std::string> split_words(const std::string& text) {
    std::vector<std::string> words;
    std::istringstream stream(text);
    std::string word;
    while (stream >> word) {
        words.push_back(word);
    }
    return words;
}

int word_count(const std::string& text) {
    const std::size_t count = split_words(text).size();
    // Guard against silent truncation of the size_t -> int cast. word_count is
    // only meaningful for inputs with fewer than INT_MAX (~2.1B) words.
    assert(count <= static_cast<std::size_t>(INT_MAX));
    return static_cast<int>(count);
}

} // namespace string_utils

The header declares the four functions and pulls in <string> and <vector>.

C++
#ifndef STRING_OPS_HPP
#define STRING_OPS_HPP

#include <string>
#include <vector>

namespace string_utils {

int word_count(const std::string& text);
std::string reverse_string(const std::string& text);
bool is_palindrome(const std::string& text);
std::vector<std::string> split_words(const std::string& text);

} // namespace string_utils

#endif // STRING_OPS_HPP

The bindings

The bindings file is where nanobind does its thing. Include the core header plus the STL conversion headers you need, alias the namespace, and register functions with NB_MODULE.

C++
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>  // Enables std::string <-> str conversion
#include <nanobind/stl/vector.h>  // Enables std::vector <-> list conversion
#include "string_ops.hpp"

namespace nb = nanobind;

NB_MODULE(_core, m) {
    m.doc() = "Native C++ string processing module created with nanobind";

    m.def("word_count", &string_utils::word_count,
          nb::arg("text"),
          "Count the number of whitespace-separated words in a string");

    m.def("reverse", &string_utils::reverse_string,
          nb::arg("text"),
          "Reverse a string");

    m.def("is_palindrome", &string_utils::is_palindrome,
          nb::arg("text"),
          "Verify if a string is a palindrome (ignoring whitespace and case; "
          "punctuation is NOT ignored)");

    m.def("split_words", &string_utils::split_words,
          nb::arg("text"),
          "Split a string into a list of words");
}

Two rules here. First, include the STL conversion headers explicitly. If you pass a std::vector<std::string> or accept a std::string without the matching <nanobind/stl/*.h>, you get a TypeError: Cannot cast Python object to C++ type at call time. Second, use nb::arg("text") to name the keyword argument so Python callers can pass by keyword.

The Python wrapper and type stubs

The public package wraps the compiled module and exports the four functions.

PYTHON
"""High-performance Python wrapper over C++ extension module."""

from string_utils._core import is_palindrome
from string_utils._core import reverse
from string_utils._core import split_words
from string_utils._core import word_count

__version__ = "0.1.0"
__all__ = ["is_palindrome", "reverse", "split_words", "word_count"]

Since the compiled _core module has no Python-level type information, ship a PEP 561 stub file. This gives IDEs and type checkers real signatures.

PYTHON
def word_count(text: str) -> int: ...
def reverse(text: str) -> str: ...
def is_palindrome(text: str) -> bool: ...
def split_words(text: str) -> list[str]: ...

A py.typed marker file alongside it tells type checkers the package is typed.

Building and testing with uv

With everything in place, the workflow is three commands.

BASH
uv sync --extra test
uv run pytest
uv run python -c "
import string_utils
print(string_utils.word_count('Fast native Python extensions using nanobind and uv.'))
print(string_utils.reverse('nanobind'))
"

uv sync resolves the build, compiles the C++ extension through scikit-build-core, and installs it into the virtual environment. From then on import string_utils works as if it were a pure-Python package.

Be honest about where the speed comes from

Writing a C++ extension is not an automatic speedup. A benchmark of this repo's word_count against Python's str.split() on a 9,000-word string shows roughly a 1x speedup: Python's split() is already a C implementation, so the extension wins nothing on that particular function.

Where C++ Actually Pays

The payoff shows up in compute-heavy kernels that Python loops cannot delegate to an existing C routine: tight numeric loops, string transforms executed per-element in Python, parser state machines, or memory layout conversions. For those, a nanobind extension removes per-element interpreter overhead and the win is real. For a thin wrapper over a call Python already makes in C, it is not. Pick the C++ boundary where Python's own interpreter loop would otherwise be the bottleneck, and benchmark before and after rather than assuming.

When you set up a real project, add pytest-benchmark (already a dev dependency here) so the invocation overhead and throughput are measured, not guessed.

Going deeper: the concepts worth knowing

Releasing the GIL

Long-running C++ work holds the GIL and blocks other Python threads. Release it while your compute loop runs so Python threads can proceed in parallel.

C++
std::vector<std::string> batch_process(const std::vector<std::string>& inputs) {
    nb::gil_scoped_release release;
    std::vector<std::string> results;
    results.reserve(inputs.size());
    for (const auto& item : inputs) results.push_back(string_utils::reverse_string(item));
    return results;
}

Zero-copy tensors with nb::ndarray

For numeric work, nb::ndarray speaks both the Python buffer protocol and DLPack, so NumPy, PyTorch, and JAX arrays share memory with C++ at zero copy cost.

C++
#include <nanobind/ndarray.h>

void scale_matrix(nb::ndarray<float, nb::shape<-1, -1>, nb::c_contig, nb::device::cpu> array, float factor) {
    float* data = array.data();
    size_t rows = array.shape(0);
    size_t cols = array.shape(1);
    for (size_t i = 0; i < rows; ++i)
        for (size_t j = 0; j < cols; ++j)
            data[i * cols + j] *= factor;
}

Return value policies

When you return a pointer or reference to Python, you must say who owns the memory. nanobind's return value policies prevent leaks and use-after-free bugs.

C++
struct NativeBuffer {
    std::string name;
    std::vector<double> data;
};

class BufferManager {
    NativeBuffer buf{"default", {1.0, 2.0, 3.0}};
public:
    NativeBuffer* get_buffer_ref() { return &buf; }
    NativeBuffer get_buffer_copy() { return buf; }
};

NB_MODULE(_policy_core, m) {
    nb::class_<NativeBuffer>(m, "NativeBuffer")
        .strip_prefix()
        .def_rw("name", &NativeBuffer::name);

    nb::class_<BufferManager>(m, "BufferManager")
        .def(nb::init<>())
        .def("get_buffer_ref", &BufferManager::get_buffer_ref, nb::rv_policy::reference_internal)
        .def("get_buffer_copy", &BufferManager::get_buffer_copy, nb::rv_policy::copy);
}

reference_internal ties the returned pointer's lifetime to the BufferManager instance. copy makes an independent Python object by value.

The stable ABI question: read the current docs

A common sales pitch is that abi3 lets one wheel serve Python 3.10 through 3.13. The current upstream behavior is more specific.

Stable ABI

Per the official nanobind docs, the STABLE_ABI flag is ignored on Python versions older than 3.12. A stable-ABI build produces an _core.abi3.so and targets the limited API from Python 3.12 onward, at a small runtime performance cost. In scikit-build-core you set wheel.py-api = "abi3" and let the backend select the version. If you need to run on versions before 3.12 too, the abi3 story does not give you a single binary, so check the current docs rather than trusting a cached blog claim.

For this demo the decision was to leave abi3 off: we are not shipping wheels to PyPI, and abi3 costs a little performance while changing the extension suffix. The steps are documented in the repo for anyone who wants them later.

Putting it together

The path to a fast, maintainable C++ extension is short: nanobind for the bindings, scikit-build-core for the packaging, and uv for the workflow.

The two subtlest lessons this project surfaced are practical. First, respect nanobind's optimization model: keep the thin bindings at -Os, and put your real, hot code in its own -O3 static library. Second, trust current upstream docs over cached blog claims: the scikit-build-core>=1.5 floor and the one-wheel-for-3.10-through-3.13 abi3 pitch are both wrong against today's stack, and both would silently break or mislead a beginner.

When you are ready to distribute a native package to other platforms, add a cibuildwheel job to CI to produce manylinux, macOS, and Windows wheels and publish them to PyPI; the repo documents the workflow. Remember to benchmark the actual hot path and skip abi3 unless you really ship wheels, because it trades a little runtime performance for a single binary across Python 3.12+.

The complete, working example is at github.com/abn/python-nanobind-demo. Clone it, run the three commands, and you have a verified C++ extension you can extend into your own fast path.