# 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](https://pydevtools.com/handbook/reference/uv.md) manages the Python project, dependencies, and virtual environment
- [scikit-build-core](https://scikit-build-core.readthedocs.io/) is the [build backend](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md) that drives CMake to compile C++ code into a Python extension module
- [pybind11](https://pybind11.readthedocs.io/) is the C++ library that bridges C++ and Python, letting you write C++ functions callable from Python

## Prerequisites

- [uv](https://pydevtools.com/handbook/reference/uv.md) installed ([installation guide](https://docs.astral.sh/uv/getting-started/installation/))
- 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:

```console
$ 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)
```
Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the "Desktop development with C++" workload. This includes both the MSVC compiler and CMake.

Verify your tools are working from a Developer Command Prompt:

```console
$ uv --version
uv 0.11.6
$ cmake --version
cmake version 3.31.6
$ cl
Microsoft (R) C/C++ Optimizing Compiler
```
## Creating the Project

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

```console
$ 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 scaffolds a `src/string_utils/` package layout (with `__init__.py` and `py.typed`) instead of a flat `main.py` script. 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:

```bash
rm -rf src
```
```bash
rmdir /s /q src
```
## Configuring the Build System

Replace the contents of [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md) with a configuration that tells [uv](https://pydevtools.com/handbook/reference/uv.md) to use scikit-build-core as the build backend:

```toml {filename="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:

```cmake {filename="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:

```bash
mkdir src
```

```cpp {filename="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:

```bash
mkdir -p python/string_utils
```
```bash
mkdir python\string_utils
```
```python {filename="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](https://pydevtools.com/handbook/explanation/what-is-a-virtual-environment.md):

```console
$ 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 wheel it produces, so subsequent `uv sync` runs reuse it instead of rebuilding when nothing has changed.

Now test the functions:

```console
$ 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:

{{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

- `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](https://pydevtools.com/handbook/tutorial/setting-up-testing-with-pytest-and-uv.md) that exercise both the C++ functions and the Python wrapper
- [Publish the package to PyPI](https://pydevtools.com/handbook/tutorial/publishing-your-first-python-package-to-pypi.md) (scikit-build-core handles building platform-specific wheels)

## Learn More

- [Complete example project on GitHub](https://github.com/python-developer-tooling-handbook/python-cpp-extension-example)
- [pybind11 documentation](https://pybind11.readthedocs.io/)
- [scikit-build-core documentation](https://scikit-build-core.readthedocs.io/)
- [What is a build backend?](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md)
- [uv documentation](https://docs.astral.sh/uv/)
- [Build a Python library with a Rust extension](https://pydevtools.com/handbook/tutorial/build-a-python-library-with-a-rust-extension.md)
