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, dependenciesAn external package your project needs, listed in pyproject.toml so tools can install it automatically. , 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
Initialized project `string-utils` at `/path/to/string_utils`
$ cd string_utils
If you see error: Project is already initialized in ..., you ran uv init inside an existing uv project. Move up a directory first.
The --lib flag scaffoldsGenerate the initial file and folder structure for a new project.
a src/string_utils/ package layout (with __init__.py and py.typed) meant to be imported, so it skips the console-script entry point a default uv init adds. You’ll restructure it next so the C++ source lives in src/ and the Python wrapper lives in python/string_utils/.
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>=1.0", "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 environmentAn isolated folder where Python installs packages for one project, keeping them separate from other projects and your system Python.
Learn more →
:
$ 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
If the build fails with Could NOT find pybind11 (missing: pybind11_DIR), CMake couldn’t locate the pybind11 config files. Confirm pybind11>=2.13 is in [build-system].requires in pyproject.toml and re-run uv sync. If the compile step errors with 'pybind11/pybind11.h' file not found or a missing C++ standard header, the C++ toolchain headers aren’t installed; revisit the Prerequisites step for your platform.
Notice the new .venv/ directory. The compiled extension lives at .venv/lib/python*/site-packages/string_utils/_core.cpython-*.so (.pyd on Windows). Python imports it as string_utils._core whenever __init__.py runs.
The first build takes longer because CMake compiles pybind11’s headers and your C++ code. uv caches the wheelA prebuilt Python package file (.whl) that installs without compiling anything. The standard distribution format for Python packages.
Learn more →
it produces, so subsequent uv sync runs reuse it instead of rebuilding when nothing has changed.
Now test the functions. uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first.
$ 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
If you see ModuleNotFoundError: No module named 'string_utils._core', the build did not produce the extension. Re-run uv sync and watch for compile errors in the build output before continuing.
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)