Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Using scikit-build-core as a plugin

Slides

Scikit-build-core is primarily a native build-backend, but you can also use it to create plugins for other build backends. We have two built-in: hatchling and setuptools. Hatchling was added by popular request, and setuptools exists mostly to allow us to replace the backend of scikit-build (classic). Both are useful, though, if you want to add a compiled extension to an existing project, or if a project is mostly Python, or if you need to combine plugins (such as building mypyc + C++).

The plugins reuse the same CMake machinery and the same [tool.scikit-build] configuration as the standalone backend, so most of what you learned in the scikit-build chapters carries over. The difference is who owns the build: the host backend builds the wheel and SDist, and scikit-build-core contributes the compiled bits as a hook.

Hatchling

Hatchling is the build backend behind Hatch, and it has a nice plugin system. To add compiled extensions to a Hatchling project, put scikit-build-core[hatchling] in your build-system.requires, then enable the plugin by adding the tool.hatch.build.targets.wheel.hooks.scikit-build table:

pyproject.toml
[build-system]
requires = ["scikit-build-core[hatchling]>=1"]
build-backend = "hatchling.build"

[project]
name = "hatchling_example"
version = "0.1.0"

[tool.hatch.build.targets.wheel.hooks.scikit-build]

The empty hooks.scikit-build table is enough to switch the plugin on; that is where you’d point CMake at a non-default source directory or set other options.

Configuration lives in either tool.hatch.build.targets.wheel.hooks.scikit-build or the usual tool.scikit-build table (or environment variables). A few backend features don’t apply, because hatchling owns that part of the build:

Inside CMakeLists.txt, the hatchling version is available as ${SKBUILD_HATCHLING}.

A full example

Here’s a mostly-Python package that adds a single C extension. The Python lives in a normal src layout, and the compiled bits sit in their own cpp directory:

hatchling
├── pyproject.toml
├── cpp
│   ├── CMakeLists.txt
│   └── example.c
└── src
    └── hatchling_example
        └── __init__.py
pyproject.toml
[build-system]
requires = ["scikit-build-core[hatchling]>=1"]
build-backend = "hatchling.build"

[project]
name = "hatchling_example"
version = "0.1.0"
requires-python = ">=3.14"

[tool.hatch.build.targets.wheel.hooks.scikit-build]
cmake.source-dir = "cpp"
wheel.install-dir = "hatchling_example"

pyproject.toml

cmake.source-dir points at the CMakeLists.txt, and wheel.install-dir sends the CMake output into the package directory so _core lands next to __init__.py.

CMakeLists.txt
cmake_minimum_required(VERSION 3.15...4.3)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C)

find_package(
  Python
  COMPONENTS Interpreter Development.Module
  REQUIRED)

python_add_library(_core MODULE example.c WITH_SOABI)
install(TARGETS _core DESTINATION .)

cpp/CMakeLists.txt

example.c
#define PY_SSIZE_T_CLEAN
#include <Python.h>

static PyObject *square(PyObject *self, PyObject *args) {
  double x;
  if (!PyArg_ParseTuple(args, "d", &x)) {
    return NULL;
  }
  return PyFloat_FromDouble(x * x);
}

static PyMethodDef methods[] = {
    {"square", square, METH_VARARGS, "Square a number"},
    {NULL, NULL, 0, NULL}};

static struct PyModuleDef module = {PyModuleDef_HEAD_INIT, "_core", NULL, -1,
                                    methods};

PyMODINIT_FUNC PyInit__core(void) { return PyModule_Create(&module); }

cpp/example.c

__init__.py
from ._core import square

__all__ = ["square"]

src/hatchling_example/init.py

Setuptools

Put scikit-build-core[setuptools] in build-system.requires. You can keep setuptools.build_meta as the backend, but using scikit_build_core.setuptools.build_meta gets you the auto-inclusion of cmake and ninja and config-settings, so it’s recommended if you can:

pyproject.toml
[build-system]
requires = ["scikit-build-core[setuptools]>=1"]
build-backend = "scikit_build_core.setuptools.build_meta"

The plugin only activates with a cmake.source-dir setting, or with the classic cmake_source_dir keyword in a minimal setup.py:

pyproject.toml (recommended)
setup.py
pyproject.toml
[tool.scikit-build]
cmake.source-dir = "."

Everything else configures through [tool.scikit-build] as normal. Config-settings (-C cmake.build-type=Debug) works when building through the PEP 517 backend, but not through setuptools.build_meta or a direct setup.py invocation. A handful of classic setup.py keywords (cmake_args, cmake_install_dir, cmake_install_target, …) are still honored for compatibility.

Since setuptools owns the SDist and editable machinery, those follow setuptools’ rules: the SDist file list comes from setuptools (only sdist.inclusion-mode = "explicit" is supported), and editable installs (pip install -e .) require opting in with editable.mode = "inplace", since they write build artifacts into your source tree.

A full example

The same package as above, but on setuptools and configured entirely from pyproject.toml — no setup.py:

setuptools
├── pyproject.toml
├── CMakeLists.txt
├── example.c
└── src
    └── setuptools_example
        └── __init__.py
pyproject.toml
[build-system]
requires = ["scikit-build-core[setuptools]>=1"]
build-backend = "scikit_build_core.setuptools.build_meta"

[project]
name = "setuptools_example"
version = "0.1.0"
requires-python = ">=3.14"

[tool.setuptools.packages.find]
where = ["src"]

[tool.scikit-build]
cmake.source-dir = "."
sdist.inclusion-mode = "explicit"
sdist.include = ["**.c"]
# Required by the setuptools plugin for `pip install -e .` / `uv run`
editable.mode = "inplace"

pyproject.toml

Setuptools still discovers the Python package (here via its normal find config), while cmake.source-dir activates the plugin and CMake installs _core into the package. The editable.mode = "inplace" line is what lets pip install -e . (or uv run) work, since the setuptools plugin only supports in-place editable installs.

CMakeLists.txt
cmake_minimum_required(VERSION 3.15...4.3)
project(${SKBUILD_PROJECT_NAME} LANGUAGES C)

find_package(
  Python
  COMPONENTS Interpreter Development.Module
  REQUIRED)

python_add_library(_core MODULE example.c WITH_SOABI)
install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME})

CMakeLists.txt

The example.c and __init__.py are identical to the hatchling example.

About plugins and build systems

The plugins exist because no single backend is the right answer for every project. A backend “owns” the build: it produces the SDist and wheel, and it decides which hooks get to contribute files along the way. Scikit-build-core as a plugin is one such hook — it runs CMake and drops the resulting extension modules into the wheel — while hatchling or setuptools handles the metadata, the Python sources, and the packaging.

That split is exactly what you want when:

If you’re starting fresh and the project is primarily a compiled extension, the standalone scikit_build_core.build backend is still the simplest path — you only reach for the plugins when you need to live inside another backend’s world.