Skip to content

Build a Python library with a C++ extension

Python handles most tasks well, but numerical code and tight loops can hit its performance ceiling. C++ removes that ceiling. This tutorial walks you through creating a Python package where the performance-critical code lives in C++, compiled into a native extension module that Python imports like any other module.

You’ll use three tools together:

  • uv manages the Python project, dependencies, and virtual environment
  • scikit-build-core is the build backend that drives CMake to compile C++ code into a Python extension module
  • pybind11 is the C++ library that bridges C++ and Python, letting you write C++ functions callable from Python

Prerequisites

Most macOS and Linux systems ship with a C++ compiler. On macOS, install the Xcode Command Line Tools (xcode-select --install). On Ubuntu/Debian, install the build-essential and cmake packages.

Verify your tools are working:

$ uv --version
uv 0.11.6 (Homebrew 2025-04-01)
$ cmake --version
cmake version 3.31.6
$ g++ --version
Apple clang version 16.0.0 (clang-1600.0.26.6)

Creating the Project

Start by creating a Python library project with uv init --lib:

uv init --lib string_utils
cd string_utils

This creates a standard Python library layout. You’ll restructure it to add C++ source code alongside the Python code.

Delete the generated src/ directory since you’ll replace it with C++ source code:

rm -rf src

Configuring the Build System

Replace the contents of pyproject.toml with a configuration that tells uv to use scikit-build-core as the build backend:

pyproject.toml
[project]
name = "string-utils"
version = "0.1.0"
description = "A Python library with C++ extensions"
readme = "README.md"
requires-python = ">=3.10"

[build-system]
requires = ["scikit-build-core>=0.11", "pybind11>=2.13"]
build-backend = "scikit_build_core.build"

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

Three things to note in [build-system]:

  • scikit-build-core replaces the default hatchling backend with one that knows how to drive CMake
  • pybind11 is listed as a build requirement because CMake needs its header files during compilation
  • The [tool.scikit-build] section tells the backend to build in Release mode and where to find the Python package

Setting Up CMake

Create a CMakeLists.txt in the project root. This tells CMake how to find pybind11 and compile the extension:

CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(string_utils LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(_core src/bindings.cpp)

install(TARGETS _core DESTINATION string_utils)

find_package(pybind11 CONFIG REQUIRED) locates the pybind11 CMake config files that ship with the pybind11 pip package. pybind11_add_module is a helper that handles all the compiler flags needed to produce a Python-loadable shared library.

Writing the C++ Code

Create the src/ directory and a source file:

mkdir src
src/bindings.cpp
#include <pybind11/pybind11.h>
#include <string>
#include <sstream>
#include <algorithm>
#include <cctype>

namespace py = pybind11;

int word_count(const std::string& text) {
    std::istringstream stream(text);
    std::string word;
    int count = 0;
    while (stream >> word) {
        count++;
    }
    return count;
}

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

bool is_palindrome(const std::string& text) {
    std::string cleaned;
    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;
}

PYBIND11_MODULE(_core, m) {
    m.doc() = "String utilities implemented in C++";
    m.def("word_count", &word_count, "Count the number of words in a string");
    m.def("reverse", &reverse, "Reverse a string");
    m.def("is_palindrome", &is_palindrome,
          "Check if a string is a palindrome (ignoring case and whitespace)");
}

The PYBIND11_MODULE macro defines the module. Each m.def() call registers a C++ function so Python can call it. The module name _core must match the target name in CMakeLists.txt and the import path in the Python wrapper.

Adding the Python Wrapper

Create a python/string_utils/ directory with an __init__.py that re-exports the C++ functions:

mkdir -p python/string_utils
python/string_utils/__init__.py
from string_utils._core import word_count, reverse, is_palindrome

__all__ = ["word_count", "reverse", "is_palindrome"]

This gives users a clean import path (from string_utils import word_count) while keeping the compiled C++ module as an implementation detail behind _core.

Building and Testing

Run uv sync to compile the C++ code and install the package into your virtual environment:

$ uv sync
Using CPython 3.12.10
Creating virtual environment at: .venv
Resolved 1 package in 18ms
   Building string-utils @ file:///root/string_utils
Installed 1 package in 9s
 + string-utils==0.1.0

The first build takes longer because CMake compiles pybind11’s headers and your C++ code. Subsequent builds reuse cached artifacts in the _skbuild/ directory.

Now test the functions:

$ uv run python -c "
from string_utils import word_count, reverse, is_palindrome

print(word_count('hello world'))
print(reverse('hello'))
print(is_palindrome('racecar'))
print(is_palindrome('Race Car'))
"
2
olleh
True
True

Project Structure

Your final project layout looks like this:

    • pyproject.toml
    • CMakeLists.txt
    • README.md
      • bindings.cpp
        • init.py
  • src/bindings.cpp contains the C++ implementation and pybind11 bindings
  • python/string_utils/__init__.py provides the Python interface
  • CMakeLists.txt configures the CMake build
  • pyproject.toml configures the Python package and tells uv to use scikit-build-core

Next Steps

From here you can:

  • Add more complex C++ functions that process large arrays or perform CPU-intensive computation
  • Write tests with pytest that exercise both the C++ functions and the Python wrapper
  • Publish the package to PyPI (scikit-build-core handles building platform-specific wheels)

Learn More

Last updated on

Please submit corrections and feedback...