Skip to main content

Command Palette

Search for a command to run...

nanobind: the bridge between C++ and Python...

Published
5 min read
S
In search of WhoAmI...

Python is where ideas move fast. C++ is where performance lives. For years, gluing the two together meant choosing between power, safety, and sanity. Enter nanobind —a modern, minimalist bridge that lets C++ and Python talk to each other cleanly, efficiently, and without ceremony.

nanobind is a C++17-first binding library , inspired by pybind11 but redesigned with a sharper focus on performance, compile times, and modern C++ practices. It’s built for developers who care about control—over lifetimes, memory, ABI stability, and error handling—without drowning in boilerplate.

What makes nanobind special is how unapologetically low-level it is, while still feeling elegant. It avoids heavy template metaprogramming where possible, compiles fast, and produces smaller binaries. If you’re working on large C++ codebases—CAD kernels, physics engines, solvers, graphics pipelines—this matters a lot.

Memory management is another strong point. nanobind gives you explicit control over object ownership between C++ and Python, making it ideal for systems where Python is embedded inside a C++ application (think Blender, FreeCAD, game engines, or simulation tools). You’re not just exporting functions—you’re designing an API boundary.

Compared to older tools like SWIG, nanobind doesn’t try to “auto-magically” generate bindings. That’s a feature, not a bug. You write bindings intentionally, so the Python API feels Pythonic, not like a leaked C++ header. And compared to pybind11, nanobind is leaner, stricter, and more future-facing.

In short:

  • Python for orchestration and scripting

  • C++ for performance-critical logic

  • nanobind as the clean, modern handshake between them

If you’re building serious software where Python is a first-class citizen—but not the performance bottleneck—nanobind is one of the best bridges you can choose today.

Here's my today's exploration on nanobind - the Factory pattern written in C++ being given a python interface using nanobind.

Enjoy...

The Factory Pattern is written in C++.

/*
 * Food.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef FOOD_H_
#define FOOD_H_
#include <string>

using namespace std;

class Food {
public:
    virtual string getName() = 0;

    virtual ~Food(){

    }
};


#endif /* FOOD_H_ */
\* Biscuit.h

\*

\* Created on: Mar 10, 2021

\* Author: som

\*/

/*
 * Biscuit.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef BISCUIT_H_
#define BISCUIT_H_

#include "Food.h"

class Biscuit: public Food {
public:
    Biscuit();
    string getName();
    ~Biscuit();
};

#endif /* BISCUIT_H_ */


/*
 * Biscuit.cpp
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */
#include <iostream>
#include "Biscuit.h"
using namespace std;


Biscuit::Biscuit() {
    // TODO Auto-generated constructor stub
    cout<<"Biscuit is made..."<<endl;

}

Biscuit::~Biscuit(){}


string Biscuit::getName(){
    return "It's a Biscuit";
}
/*
 * Chocolate.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef CHOCOLATE_H_
#define CHOCOLATE_H_

#include <iostream>
#include "Food.h"

class Chocolate: public Food {
public:
    Chocolate();
    virtual ~Chocolate();
    string getName();
};


#endif /* CHOCOLATE_H_ */


/*
 * Chocolate.cpp
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#include "Chocolate.h"


Chocolate::Chocolate() {
    // TODO Auto-generated constructor stub
    cout<<"Chocolate is made..."<<endl;

}

Chocolate::~Chocolate() {
    // TODO Auto-generated destructor stub
}

string Chocolate::getName(){
    return "It's a Chocolate";
}
/*
 * Factory.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef FACTORY_H_
#define FACTORY_H_

#include <nanobind/nanobind.h>
#include <iostream>
#include <string>

#include "Biscuit.h"
#include "Chocolate.h"

using namespace std;

class Factory{
public:
    static Factory* instance;
    static Factory* getInstance();

    Food* makeFood(const string& type);

private:
    Factory(){}

    // Delete copy constructor & assignment operator (Singleton pattern)
    Factory(const Factory&) = delete;
    Factory& operator=(const Factory&) = delete;
};
//Factory* Factory:: instance =  NULL;


#endif /* FACTORY_H_ */


/*
 * Factory.cpp
 *
 *  Created on: Jan 30, 2025
 *      Author: som
 */
#include "Factory.h"
Factory* Factory::instance = NULL;

Factory* Factory:: getInstance(){
        if(Factory::instance == NULL){
            Factory::instance = new Factory();
        }
        return Factory::instance;
    }

Food* Factory::makeFood(const string& type){
        if(type.compare("bi") == 0){
            return new Biscuit();
        }
        if(type.compare("ch") == 0){
            return new Chocolate();
        }

        return NULL;
    }

The bindings.cpp - defining the bridge...

#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h> // Required for std::string support
#include "Factory.h"

namespace nb = nanobind;

// Use NB_MODULE to define the extension
NB_MODULE(foodfactory, m) {
    // 1. Wrap the Base Class
    // We use nb::class_<T> and provide the name it will have in Python
    nb::class_<Food>(m, "Food")
        .def("getName", &Food::getName);

    // 2. Wrap the Subclasses
    // Note: We specify the base class <Biscuit, Food> so Python knows the relationship
    nb::class_<Biscuit, Food>(m, "Biscuit")
        .def(nb::init<>());

    nb::class_<Chocolate, Food>(m, "Chocolate")
        .def(nb::init<>());

    // 3. Wrap the Singleton Factory
        nb::class_<Factory>(m, "Factory")
            .def_static("get_instance", &Factory::getInstance, // Renamed to snake_case for Python idiomatic style
                        nb::rv_policy::reference)

            .def("make_food", &Factory::makeFood, // Match your Python script's call
                 nb::rv_policy::take_ownership);
}
## And here is the CMakeLists.txt for compiling and creating the shared object

cmake_minimum_required(VERSION 3.15)
project(FactoryPattern_nanobind)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 1. Find Python
find_package(Python 3.9 COMPONENTS Interpreter Development REQUIRED)

# 2. Get nanobind paths manually to bypass the "Target Not Found" bug
execute_process(
    COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
    OUTPUT_VARIABLE NB_DIR OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
    COMMAND "${Python_EXECUTABLE}" -m nanobind --include_dir
    OUTPUT_VARIABLE NB_INC OUTPUT_STRIP_TRAILING_WHITESPACE
)

# 3. Load the nanobind logic
list(APPEND CMAKE_PREFIX_PATH "${NB_DIR}")
find_package(nanobind REQUIRED CONFIG)

# 4. Create the module
nanobind_add_module(foodfactory
    LTO
    Biscuit.cpp
    Chocolate.cpp
    Factory.cpp
    bindings.cpp
)

# 5. MANUALLY fix the missing target link
# This bypasses the error by providing the includes and libraries directly
target_include_directories(foodfactory PRIVATE "${NB_INC}" "${Python_INCLUDE_DIRS}")
target_link_libraries(foodfactory PRIVATE Python::Module)

# If nanobind still complains about the target, we define it as an alias
if(NOT TARGET nanobind::nanobind)
    add_library(nanobind::nanobind INTERFACE IMPORTED)
    set_target_properties(nanobind::nanobind PROPERTIES
        INTERFACE_INCLUDE_DIRECTORIES "${NB_INC}"
    )
endif()

After compiling and creating the shared object, you need to put it inside the Python project and import it.

The Python code looks as follows.

import foodfactory
# Access get_instance THROUGH the Factory class
factory = foodfactory.Factory.get_instance()
biscuit = factory.make_food("bi")
print(biscuit.getName())
chocolate = factory.make_food("ch")
print(chocolate.getName())

Enjoy...

More from this blog

Som's Tech World...

149 posts

I am in search of WhoAmI. This is my own world of technologies and software. I believe in the following simple fact of life... To win is no more than this... To rise each time you fall...