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
- uv installed (installation guide)
- A C++ compiler (g++ or clang++)
- CMake 3.15 or later
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_utilsThis 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 srcConfiguring the Build System
Replace the contents of pyproject.toml with a configuration that tells uv to use scikit-build-core as the build backend:
[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-corereplaces the defaulthatchlingbackend with one that knows how to drive CMakepybind11is 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:
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#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_utilsfrom 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.cppcontains the C++ implementation and pybind11 bindingspython/string_utils/__init__.pyprovides the Python interfaceCMakeLists.txtconfigures the CMake buildpyproject.tomlconfigures 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)