[feat/precompile-macro-infra] feat(cmake): opt-in precompiled static library, starting with pytypes.h (#6132)

* feat(cmake): opt-in precompiled pybind11 static library, starting with pytypes.h

Split the out-of-line pytypes.h definitions into pytypes-inl.h (fmtlib/
CLI11 style): inline by default, compiled once into a per-project static
library when PYBIND11_PRECOMPILED is defined. Infrastructure:

- pybind11_precompile() creates the lazy pybind11::precompiled STATIC
  library from the installed or in-tree src/ sources; PRECOMPILE /
  NO_PRECOMPILE keywords on pybind11_add_module and a global
  PYBIND11_PRECOMPILE switch select it per target.
- A link-time guard symbol encodes PYBIND11_INTERNALS_VERSION,
  Py_GIL_DISABLED, PYBIND11_SIMPLE_GIL_MANAGEMENT, and
  PYBIND11_DETAILED_ERROR_MESSAGES, so a configuration mismatch is one
  readable undefined symbol.
- src/ is installed to share/pybind11/src (wheel stays pure);
  src/pybind11_combined.cpp is a single-TU build for non-CMake use.
- Tests: PYBIND11_TEST_PRECOMPILE builds the whole suite against the
  library, two new test_cmake_build cases, packaging file lists, tidy
  preset, and a 3-platform CI job.

Assisted-by: ClaudeCode:claude-fable-5

* fix(tidy): keep the tidy preset header-only

In precompiled mode the -inl.h definitions are intentionally
non-inline, so misc-definitions-in-headers fires on every one. The
header-only tidy build already analyzes all -inl.h bodies via the
bottom-of-header includes.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cmake): compile the precompiled library with the interpreter ABI macros

pybind11::pybind11 only carries headers; Py_GIL_DISABLED lives on
Python::Module via pybind11::module. Without it the library is
ABI-mismatched on free-threaded builds, and on Windows the pyconfig.h
autolink pragma in its objects requests pythonXY.lib instead of
pythonXYt.lib.

Assisted-by: ClaudeCode:claude-fable-5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0aa06c1..4b300bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,6 +74,31 @@
       python-version: ${{ matrix.python-version }}
       cmake-args: ${{ matrix.cmake-args }}
 
+  # Build the test suite against the precompiled pybind11 static library; a
+  # definition missing from the -inl.h split shows up here as a link error.
+  precompile:
+    if: github.event.pull_request.draft == false
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - runs-on: ubuntu-latest
+            python-version: '3.13'
+          # free-threaded: compiles the Py_GIL_DISABLED-only code in the -inl.h files
+          - runs-on: ubuntu-latest
+            python-version: '3.14t'
+          - runs-on: macos-latest
+            python-version: '3.13'
+          - runs-on: windows-2022
+            python-version: '3.13'
+
+    name: ⚡
+    uses: ./.github/workflows/reusable-standard.yml
+    with:
+      runs-on: ${{ matrix.runs-on }}
+      python-version: ${{ matrix.python-version }}
+      cmake-args: -DPYBIND11_TEST_PRECOMPILE=ON
+
   standard-large:
     if: github.event.pull_request.draft == false
     strategy:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 65f794a..b65529c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -200,6 +200,7 @@
     include/pybind11/detail/function_ref.h
     include/pybind11/detail/holder_caster_foreign_helpers.h
     include/pybind11/detail/init.h
+    include/pybind11/detail/internals-inl.h
     include/pybind11/detail/internals.h
     include/pybind11/detail/native_enum_data.h
     include/pybind11/detail/pybind11_namespace_macros.h
@@ -234,6 +235,7 @@
     include/pybind11/numpy.h
     include/pybind11/operators.h
     include/pybind11/pybind11.h
+    include/pybind11/pytypes-inl.h
     include/pybind11/pytypes.h
     include/pybind11/subinterpreter.h
     include/pybind11/stl.h
@@ -265,6 +267,11 @@
 
 list(TRANSFORM PYBIND11_HEADERS PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
 
+# Library sources for the opt-in precompiled mode (pybind11_precompile()).
+set(pybind11_SRC_DIR
+    "${CMAKE_CURRENT_SOURCE_DIR}/src"
+    CACHE INTERNAL "Directory containing the pybind11 library sources")
+
 # Cache variable so this can be used in parent projects
 set(pybind11_INCLUDE_DIR
     "${CMAKE_CURRENT_LIST_DIR}/include"
@@ -331,6 +338,8 @@
     install(DIRECTORY ${pybind11_INCLUDE_DIR}/pybind11 DESTINATION "${SKBUILD_HEADERS_DIR}")
   endif()
   install(DIRECTORY ${pybind11_INCLUDE_DIR}/pybind11 DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
+  install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/
+          DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
   set(PYBIND11_CMAKECONFIG_INSTALL_DIR
       "${CMAKE_INSTALL_DATAROOTDIR}/cmake/${PROJECT_NAME}"
       CACHE STRING "install path for pybind11Config.cmake")
@@ -340,6 +349,11 @@
   else()
     set(pybind11_INCLUDEDIR "\$\{PACKAGE_PREFIX_DIR\}/${CMAKE_INSTALL_INCLUDEDIR}")
   endif()
+  if(IS_ABSOLUTE "${CMAKE_INSTALL_DATAROOTDIR}")
+    set(pybind11_SRCDIR "${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
+  else()
+    set(pybind11_SRCDIR "\$\{PACKAGE_PREFIX_DIR\}/${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
+  endif()
 
   configure_package_config_file(
     tools/${PROJECT_NAME}Config.cmake.in "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
diff --git a/docs/Doxyfile b/docs/Doxyfile
index 09138db..d0c184c 100644
--- a/docs/Doxyfile
+++ b/docs/Doxyfile
@@ -1,6 +1,7 @@
 PROJECT_NAME           = pybind11
 INPUT                  = ../include/pybind11/
 RECURSIVE              = YES
+EXCLUDE_PATTERNS       = *-inl.h
 
 GENERATE_HTML          = NO
 GENERATE_LATEX         = NO
diff --git a/include/pybind11/detail/common.h b/include/pybind11/detail/common.h
index 1a0db1f..f65f69a 100644
--- a/include/pybind11/detail/common.h
+++ b/include/pybind11/detail/common.h
@@ -444,6 +444,7 @@
     static PyObject *pybind11_init();                                                             \
     PYBIND11_PLUGIN_IMPL(name) {                                                                  \
         PYBIND11_CHECK_PYTHON_VERSION                                                             \
+        PYBIND11_PRECOMPILED_CONFIG_GUARD                                                         \
         PYBIND11_ENSURE_INTERNALS_READY                                                           \
         try {                                                                                     \
             return pybind11_init();                                                               \
@@ -468,6 +469,7 @@
     static int PYBIND11_CONCAT(pybind11_exec_, name)(PyObject *);                                 \
     PYBIND11_PLUGIN_IMPL(name) {                                                                  \
         PYBIND11_CHECK_PYTHON_VERSION                                                             \
+        PYBIND11_PRECOMPILED_CONFIG_GUARD                                                         \
         try {                                                                                     \
             pybind11::detail::ensure_internals();                                                 \
             static ::pybind11::detail::slots_array mod_def_slots                                  \
diff --git a/include/pybind11/detail/internals-inl.h b/include/pybind11/detail/internals-inl.h
new file mode 100644
index 0000000..ce2c803
--- /dev/null
+++ b/include/pybind11/detail/internals-inl.h
@@ -0,0 +1,28 @@
+/*
+    pybind11/detail/internals-inl.h: Out-of-line definitions for internals.h
+
+    Copyright (c) 2017 Wenzel Jakob <wenzel.jakob@epfl.ch>
+
+    All rights reserved. Use of this source code is governed by a
+    BSD-style license that can be found in the LICENSE file.
+*/
+
+// Every function defined here must start with PYBIND11_INLINE (or
+// PYBIND11_NOINLINE_ATTR PYBIND11_INLINE). In the default header-only mode this file is
+// included at the bottom of internals.h; when PYBIND11_PRECOMPILED is defined it is only
+// compiled into the pybind11 static library (see src/).
+
+#pragma once
+
+#include "internals.h"
+
+PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
+PYBIND11_NAMESPACE_BEGIN(detail)
+
+#if defined(PYBIND11_PRECOMPILED)
+// Link-time configuration guard; see the declaration in internals.h.
+PYBIND11_INLINE void PYBIND11_PRECOMPILED_CONFIG_CHECK() {}
+#endif
+
+PYBIND11_NAMESPACE_END(detail)
+PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)
diff --git a/include/pybind11/detail/internals.h b/include/pybind11/detail/internals.h
index ffb6635..764fe17 100644
--- a/include/pybind11/detail/internals.h
+++ b/include/pybind11/detail/internals.h
@@ -46,8 +46,64 @@
 #    error "PYBIND11_INTERNALS_VERSION 12 is the minimum for all platforms for pybind11 v3.1.0"
 #endif
 
+#if defined(PYBIND11_PRECOMPILED)
+// PYBIND11_PRECOMPILED_CONFIG_CHECK names a do-nothing function defined in the precompiled
+// pybind11 library. The identifier encodes every configuration macro that must match between
+// the library and the modules linking it. PYBIND11_MODULE calls it, so a mismatch (or a
+// missing library) surfaces as one readable undefined symbol at link time instead of many
+// unrelated ones at run time. If you add a configuration macro that changes the code in the
+// -inl.h files, encode it here and add it to the list in docs/compiling.rst.
+#    if defined(Py_GIL_DISABLED)
+#        define PYBIND11_PRECOMPILED_CFG_GD 1
+#    else
+#        define PYBIND11_PRECOMPILED_CFG_GD 0
+#    endif
+#    if defined(PYBIND11_SIMPLE_GIL_MANAGEMENT)
+#        define PYBIND11_PRECOMPILED_CFG_SG 1
+#    else
+#        define PYBIND11_PRECOMPILED_CFG_SG 0
+#    endif
+#    if defined(PYBIND11_DETAILED_ERROR_MESSAGES)
+#        define PYBIND11_PRECOMPILED_CFG_DE 1
+#    else
+#        define PYBIND11_PRECOMPILED_CFG_DE 0
+#    endif
+#    if defined(PYBIND11_HAS_SUBINTERPRETER_SUPPORT)
+#        define PYBIND11_PRECOMPILED_CFG_SI 1
+#    else
+#        define PYBIND11_PRECOMPILED_CFG_SI 0
+#    endif
+#    if defined(PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET)
+#        define PYBIND11_PRECOMPILED_CFG_TD 1
+#    else
+#        define PYBIND11_PRECOMPILED_CFG_TD 0
+#    endif
+// PYBIND11_CONCAT does not macro-expand its arguments (## suppresses expansion).
+#    define PYBIND11_PRECOMPILED_CONFIG_NAME_(v, gd, sg, de, si, td)                              \
+        pybind11_precompiled_config_v##v##_gd##gd##_sg##sg##_de##de##_si##si##_td##td
+#    define PYBIND11_PRECOMPILED_CONFIG_NAME(v, gd, sg, de, si, td)                               \
+        PYBIND11_PRECOMPILED_CONFIG_NAME_(v, gd, sg, de, si, td)
+#    define PYBIND11_PRECOMPILED_CONFIG_CHECK                                                     \
+        PYBIND11_PRECOMPILED_CONFIG_NAME(PYBIND11_INTERNALS_VERSION,                              \
+                                         PYBIND11_PRECOMPILED_CFG_GD,                             \
+                                         PYBIND11_PRECOMPILED_CFG_SG,                             \
+                                         PYBIND11_PRECOMPILED_CFG_DE,                             \
+                                         PYBIND11_PRECOMPILED_CFG_SI,                             \
+                                         PYBIND11_PRECOMPILED_CFG_TD)
+#    define PYBIND11_PRECOMPILED_CONFIG_GUARD                                                     \
+        ::pybind11::detail::PYBIND11_PRECOMPILED_CONFIG_CHECK();
+#else
+#    define PYBIND11_PRECOMPILED_CONFIG_GUARD
+#endif
+
 PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
 
+#if defined(PYBIND11_PRECOMPILED)
+PYBIND11_NAMESPACE_BEGIN(detail)
+void PYBIND11_PRECOMPILED_CONFIG_CHECK();
+PYBIND11_NAMESPACE_END(detail)
+#endif
+
 using ExceptionTranslator = void (*)(std::exception_ptr);
 
 // The old Python Thread Local Storage (TLS) API is deprecated in Python 3.7 in favor of the new
@@ -1084,3 +1140,7 @@
 }
 
 PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)
+
+#ifndef PYBIND11_PRECOMPILED
+#    include "internals-inl.h" // IWYU pragma: export
+#endif
diff --git a/include/pybind11/pytypes-inl.h b/include/pybind11/pytypes-inl.h
new file mode 100644
index 0000000..f2eea10
--- /dev/null
+++ b/include/pybind11/pytypes-inl.h
@@ -0,0 +1,280 @@
+/*
+    pybind11/pytypes-inl.h: Out-of-line definitions for pytypes.h
+
+    Copyright (c) 2016 Wenzel Jakob <wenzel.jakob@epfl.ch>
+
+    All rights reserved. Use of this source code is governed by a
+    BSD-style license that can be found in the LICENSE file.
+*/
+
+// Every function defined here must start with PYBIND11_INLINE (or
+// PYBIND11_NOINLINE_ATTR PYBIND11_INLINE). In the default header-only mode this file is
+// included at the bottom of pytypes.h; when PYBIND11_PRECOMPILED is defined it is only
+// compiled into the pybind11 static library (see src/).
+
+#pragma once
+
+#include "pytypes.h"
+
+#include <string>
+
+PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
+PYBIND11_NAMESPACE_BEGIN(detail)
+
+PYBIND11_INLINE error_fetch_and_normalize::error_fetch_and_normalize(const char *called) {
+    PyErr_Fetch(&m_type.ptr(), &m_value.ptr(), &m_trace.ptr());
+    if (!m_type) {
+        pybind11_fail("Internal error: " + std::string(called)
+                      + " called while "
+                        "Python error indicator not set.");
+    }
+    const char *exc_type_name_orig = detail::obj_class_name(m_type.ptr());
+    if (exc_type_name_orig == nullptr) {
+        pybind11_fail("Internal error: " + std::string(called)
+                      + " failed to obtain the name "
+                        "of the original active exception type.");
+    }
+    m_lazy_error_string = exc_type_name_orig;
+#if PY_VERSION_HEX >= 0x030C0000
+    // The presence of __notes__ is likely due to exception normalization
+    // errors, although that is not necessarily true, therefore insert a
+    // hint only:
+    const int has_notes = PyObject_HasAttrString(m_value.ptr(), "__notes__");
+    if (has_notes == 1) {
+        m_lazy_error_string += "[WITH __notes__]";
+    } else if (has_notes == -1) {
+        // Ignore secondary errors when probing for __notes__ to avoid leaking a
+        // spurious exception while still reporting the original error.
+        PyErr_Clear();
+    }
+#else
+    // PyErr_NormalizeException() may change the exception type if there are cascading
+    // failures. This can potentially be extremely confusing.
+    PyErr_NormalizeException(&m_type.ptr(), &m_value.ptr(), &m_trace.ptr());
+    if (m_type.ptr() == nullptr) {
+        pybind11_fail("Internal error: " + std::string(called)
+                      + " failed to normalize the "
+                        "active exception.");
+    }
+    const char *exc_type_name_norm = detail::obj_class_name(m_type.ptr());
+    if (exc_type_name_norm == nullptr) {
+        pybind11_fail("Internal error: " + std::string(called)
+                      + " failed to obtain the name "
+                        "of the normalized active exception type.");
+    }
+    if (exc_type_name_norm != m_lazy_error_string) {
+        std::string msg = std::string(called)
+                          + ": MISMATCH of original and normalized "
+                            "active exception types: ";
+        msg += "ORIGINAL ";
+        msg += m_lazy_error_string;
+        msg += " REPLACED BY ";
+        msg += exc_type_name_norm;
+        msg += ": " + format_value_and_trace();
+        pybind11_fail(msg);
+    }
+#endif
+}
+
+PYBIND11_INLINE std::string error_fetch_and_normalize::format_value_and_trace() const {
+    std::string result;
+    std::string message_error_string;
+    if (m_value) {
+        auto value_str = reinterpret_steal<object>(PyObject_Str(m_value.ptr()));
+        constexpr const char *message_unavailable_exc
+            = "<MESSAGE UNAVAILABLE DUE TO ANOTHER EXCEPTION>";
+        if (!value_str) {
+            message_error_string = detail::error_string();
+            result = message_unavailable_exc;
+        } else {
+            // Not using `value_str.cast<std::string>()`, to not potentially throw a secondary
+            // error_already_set that will then result in process termination (#4288).
+            auto value_bytes = reinterpret_steal<object>(
+                PyUnicode_AsEncodedString(value_str.ptr(), "utf-8", "backslashreplace"));
+            if (!value_bytes) {
+                message_error_string = detail::error_string();
+                result = message_unavailable_exc;
+            } else {
+                char *buffer = nullptr;
+                Py_ssize_t length = 0;
+                if (PyBytes_AsStringAndSize(value_bytes.ptr(), &buffer, &length) == -1) {
+                    message_error_string = detail::error_string();
+                    result = message_unavailable_exc;
+                } else {
+                    result = std::string(buffer, static_cast<std::size_t>(length));
+                }
+            }
+        }
+#if PY_VERSION_HEX >= 0x030B0000
+        auto notes = reinterpret_steal<object>(PyObject_GetAttrString(m_value.ptr(), "__notes__"));
+        if (!notes) {
+            PyErr_Clear(); // No notes is good news.
+        } else {
+            auto len_notes = PyList_Size(notes.ptr());
+            if (len_notes < 0) {
+                result += "\nFAILURE obtaining len(__notes__): " + detail::error_string();
+            } else {
+                result += "\n__notes__ (len=" + std::to_string(len_notes) + "):";
+                for (ssize_t i = 0; i < len_notes; i++) {
+                    PyObject *note = PyList_GET_ITEM(notes.ptr(), i);
+                    auto note_bytes = reinterpret_steal<object>(
+                        PyUnicode_AsEncodedString(note, "utf-8", "backslashreplace"));
+                    if (!note_bytes) {
+                        result += "\nFAILURE obtaining __notes__[" + std::to_string(i)
+                                  + "]: " + detail::error_string();
+                    } else {
+                        char *buffer = nullptr;
+                        Py_ssize_t length = 0;
+                        if (PyBytes_AsStringAndSize(note_bytes.ptr(), &buffer, &length) == -1) {
+                            result += "\nFAILURE formatting __notes__[" + std::to_string(i)
+                                      + "]: " + detail::error_string();
+                        } else {
+                            result += '\n';
+                            result += std::string(buffer, static_cast<std::size_t>(length));
+                        }
+                    }
+                }
+            }
+        }
+#endif
+    } else {
+        result = "<MESSAGE UNAVAILABLE>";
+    }
+    if (result.empty()) {
+        result = "<EMPTY MESSAGE>";
+    }
+
+    bool have_trace = false;
+    if (m_trace) {
+#if !defined(PYPY_VERSION) && !defined(GRAALVM_PYTHON)
+        auto *tb = reinterpret_cast<PyTracebackObject *>(m_trace.ptr());
+
+        // Get the deepest trace possible.
+        while (tb->tb_next) {
+            tb = tb->tb_next;
+        }
+
+        PyFrameObject *frame = tb->tb_frame;
+        Py_XINCREF(frame);
+        result += "\n\nAt:\n";
+        while (frame) {
+            PyCodeObject *f_code = PyFrame_GetCode(frame);
+            int lineno = PyFrame_GetLineNumber(frame);
+            result += "  ";
+            result += handle(f_code->co_filename).cast<std::string>();
+            result += '(';
+            result += std::to_string(lineno);
+            result += "): ";
+            result += handle(f_code->co_name).cast<std::string>();
+            result += '\n';
+            Py_DECREF(f_code);
+            auto *b_frame = PyFrame_GetBack(frame);
+            Py_DECREF(frame);
+            frame = b_frame;
+        }
+
+        have_trace = true;
+#endif //! defined(PYPY_VERSION)
+    }
+
+    if (!message_error_string.empty()) {
+        if (!have_trace) {
+            result += '\n';
+        }
+        result += "\nMESSAGE UNAVAILABLE DUE TO EXCEPTION: " + message_error_string;
+    }
+
+    return result;
+}
+
+PYBIND11_INLINE std::string const &error_fetch_and_normalize::error_string() const {
+    if (!m_lazy_error_string_completed) {
+        m_lazy_error_string += ": " + format_value_and_trace();
+        m_lazy_error_string_completed = true;
+    }
+    return m_lazy_error_string;
+}
+
+PYBIND11_INLINE void error_fetch_and_normalize::restore() {
+    if (m_restore_called) {
+        pybind11_fail("Internal error: pybind11::detail::error_fetch_and_normalize::restore() "
+                      "called a second time. ORIGINAL ERROR: "
+                      + error_string());
+    }
+    PyErr_Restore(m_type.inc_ref().ptr(), m_value.inc_ref().ptr(), m_trace.inc_ref().ptr());
+    m_restore_called = true;
+}
+
+PYBIND11_INLINE std::string error_string() {
+    return error_fetch_and_normalize("pybind11::detail::error_string").error_string();
+}
+
+PYBIND11_NAMESPACE_END(detail)
+
+PYBIND11_INLINE void raise_from(PyObject *type, const char *message) {
+    // Based on _PyErr_FormatVFromCause:
+    // https://github.com/python/cpython/blob/467ab194fc6189d9f7310c89937c51abeac56839/Python/errors.c#L405
+    // See https://github.com/pybind/pybind11/pull/2112 for details.
+    PyObject *exc = nullptr, *val = nullptr, *val2 = nullptr, *tb = nullptr;
+
+    assert(PyErr_Occurred());
+    PyErr_Fetch(&exc, &val, &tb);
+    PyErr_NormalizeException(&exc, &val, &tb);
+    if (tb != nullptr) {
+        PyException_SetTraceback(val, tb);
+        Py_DECREF(tb);
+    }
+    Py_DECREF(exc);
+    assert(!PyErr_Occurred());
+
+    PyErr_SetString(type, message);
+
+    PyErr_Fetch(&exc, &val2, &tb);
+    PyErr_NormalizeException(&exc, &val2, &tb);
+    Py_INCREF(val);
+    PyException_SetCause(val2, val);
+    PyException_SetContext(val2, val);
+    PyErr_Restore(exc, val2, tb);
+}
+
+PYBIND11_INLINE void raise_from(error_already_set &err, PyObject *type, const char *message) {
+    err.restore();
+    raise_from(type, message);
+}
+
+/// @cond DUPLICATE
+PYBIND11_INLINE memoryview memoryview::from_buffer(void *ptr,
+                                                   ssize_t itemsize,
+                                                   const char *format,
+                                                   detail::any_container<ssize_t> shape,
+                                                   detail::any_container<ssize_t> strides,
+                                                   bool readonly) {
+    size_t ndim = shape->size();
+    if (ndim != strides->size()) {
+        pybind11_fail("memoryview: shape length doesn't match strides length");
+    }
+    ssize_t size = ndim != 0u ? 1 : 0;
+    for (size_t i = 0; i < ndim; ++i) {
+        size *= (*shape)[i];
+    }
+    Py_buffer view;
+    view.buf = ptr;
+    view.obj = nullptr;
+    view.len = size * itemsize;
+    view.readonly = static_cast<int>(readonly);
+    view.itemsize = itemsize;
+    view.format = const_cast<char *>(format);
+    view.ndim = static_cast<int>(ndim);
+    view.shape = shape->data();
+    view.strides = strides->data();
+    view.suboffsets = nullptr;
+    view.internal = nullptr;
+    PyObject *obj = PyMemoryView_FromBuffer(&view);
+    if (!obj) {
+        throw error_already_set();
+    }
+    return memoryview(object(obj, stolen_t{}));
+}
+/// @endcond
+
+PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)
diff --git a/include/pybind11/pytypes.h b/include/pybind11/pytypes.h
index 13a6eba..71cc31b 100644
--- a/include/pybind11/pytypes.h
+++ b/include/pybind11/pytypes.h
@@ -528,194 +528,16 @@
     //     would be more complex.
     // Starting with Python 3.12, PyErr_Fetch() normalizes exceptions immediately.
     // Any errors during normalization are tracked under __notes__.
-    explicit error_fetch_and_normalize(const char *called) {
-        PyErr_Fetch(&m_type.ptr(), &m_value.ptr(), &m_trace.ptr());
-        if (!m_type) {
-            pybind11_fail("Internal error: " + std::string(called)
-                          + " called while "
-                            "Python error indicator not set.");
-        }
-        const char *exc_type_name_orig = detail::obj_class_name(m_type.ptr());
-        if (exc_type_name_orig == nullptr) {
-            pybind11_fail("Internal error: " + std::string(called)
-                          + " failed to obtain the name "
-                            "of the original active exception type.");
-        }
-        m_lazy_error_string = exc_type_name_orig;
-#if PY_VERSION_HEX >= 0x030C0000
-        // The presence of __notes__ is likely due to exception normalization
-        // errors, although that is not necessarily true, therefore insert a
-        // hint only:
-        const int has_notes = PyObject_HasAttrString(m_value.ptr(), "__notes__");
-        if (has_notes == 1) {
-            m_lazy_error_string += "[WITH __notes__]";
-        } else if (has_notes == -1) {
-            // Ignore secondary errors when probing for __notes__ to avoid leaking a
-            // spurious exception while still reporting the original error.
-            PyErr_Clear();
-        }
-#else
-        // PyErr_NormalizeException() may change the exception type if there are cascading
-        // failures. This can potentially be extremely confusing.
-        PyErr_NormalizeException(&m_type.ptr(), &m_value.ptr(), &m_trace.ptr());
-        if (m_type.ptr() == nullptr) {
-            pybind11_fail("Internal error: " + std::string(called)
-                          + " failed to normalize the "
-                            "active exception.");
-        }
-        const char *exc_type_name_norm = detail::obj_class_name(m_type.ptr());
-        if (exc_type_name_norm == nullptr) {
-            pybind11_fail("Internal error: " + std::string(called)
-                          + " failed to obtain the name "
-                            "of the normalized active exception type.");
-        }
-        if (exc_type_name_norm != m_lazy_error_string) {
-            std::string msg = std::string(called)
-                              + ": MISMATCH of original and normalized "
-                                "active exception types: ";
-            msg += "ORIGINAL ";
-            msg += m_lazy_error_string;
-            msg += " REPLACED BY ";
-            msg += exc_type_name_norm;
-            msg += ": " + format_value_and_trace();
-            pybind11_fail(msg);
-        }
-#endif
-    }
+    explicit error_fetch_and_normalize(const char *called);
 
     error_fetch_and_normalize(const error_fetch_and_normalize &) = delete;
     error_fetch_and_normalize(error_fetch_and_normalize &&) = delete;
 
-    std::string format_value_and_trace() const {
-        std::string result;
-        std::string message_error_string;
-        if (m_value) {
-            auto value_str = reinterpret_steal<object>(PyObject_Str(m_value.ptr()));
-            constexpr const char *message_unavailable_exc
-                = "<MESSAGE UNAVAILABLE DUE TO ANOTHER EXCEPTION>";
-            if (!value_str) {
-                message_error_string = detail::error_string();
-                result = message_unavailable_exc;
-            } else {
-                // Not using `value_str.cast<std::string>()`, to not potentially throw a secondary
-                // error_already_set that will then result in process termination (#4288).
-                auto value_bytes = reinterpret_steal<object>(
-                    PyUnicode_AsEncodedString(value_str.ptr(), "utf-8", "backslashreplace"));
-                if (!value_bytes) {
-                    message_error_string = detail::error_string();
-                    result = message_unavailable_exc;
-                } else {
-                    char *buffer = nullptr;
-                    Py_ssize_t length = 0;
-                    if (PyBytes_AsStringAndSize(value_bytes.ptr(), &buffer, &length) == -1) {
-                        message_error_string = detail::error_string();
-                        result = message_unavailable_exc;
-                    } else {
-                        result = std::string(buffer, static_cast<std::size_t>(length));
-                    }
-                }
-            }
-#if PY_VERSION_HEX >= 0x030B0000
-            auto notes
-                = reinterpret_steal<object>(PyObject_GetAttrString(m_value.ptr(), "__notes__"));
-            if (!notes) {
-                PyErr_Clear(); // No notes is good news.
-            } else {
-                auto len_notes = PyList_Size(notes.ptr());
-                if (len_notes < 0) {
-                    result += "\nFAILURE obtaining len(__notes__): " + detail::error_string();
-                } else {
-                    result += "\n__notes__ (len=" + std::to_string(len_notes) + "):";
-                    for (ssize_t i = 0; i < len_notes; i++) {
-                        PyObject *note = PyList_GET_ITEM(notes.ptr(), i);
-                        auto note_bytes = reinterpret_steal<object>(
-                            PyUnicode_AsEncodedString(note, "utf-8", "backslashreplace"));
-                        if (!note_bytes) {
-                            result += "\nFAILURE obtaining __notes__[" + std::to_string(i)
-                                      + "]: " + detail::error_string();
-                        } else {
-                            char *buffer = nullptr;
-                            Py_ssize_t length = 0;
-                            if (PyBytes_AsStringAndSize(note_bytes.ptr(), &buffer, &length)
-                                == -1) {
-                                result += "\nFAILURE formatting __notes__[" + std::to_string(i)
-                                          + "]: " + detail::error_string();
-                            } else {
-                                result += '\n';
-                                result += std::string(buffer, static_cast<std::size_t>(length));
-                            }
-                        }
-                    }
-                }
-            }
-#endif
-        } else {
-            result = "<MESSAGE UNAVAILABLE>";
-        }
-        if (result.empty()) {
-            result = "<EMPTY MESSAGE>";
-        }
+    std::string format_value_and_trace() const;
 
-        bool have_trace = false;
-        if (m_trace) {
-#if !defined(PYPY_VERSION) && !defined(GRAALVM_PYTHON)
-            auto *tb = reinterpret_cast<PyTracebackObject *>(m_trace.ptr());
+    std::string const &error_string() const;
 
-            // Get the deepest trace possible.
-            while (tb->tb_next) {
-                tb = tb->tb_next;
-            }
-
-            PyFrameObject *frame = tb->tb_frame;
-            Py_XINCREF(frame);
-            result += "\n\nAt:\n";
-            while (frame) {
-                PyCodeObject *f_code = PyFrame_GetCode(frame);
-                int lineno = PyFrame_GetLineNumber(frame);
-                result += "  ";
-                result += handle(f_code->co_filename).cast<std::string>();
-                result += '(';
-                result += std::to_string(lineno);
-                result += "): ";
-                result += handle(f_code->co_name).cast<std::string>();
-                result += '\n';
-                Py_DECREF(f_code);
-                auto *b_frame = PyFrame_GetBack(frame);
-                Py_DECREF(frame);
-                frame = b_frame;
-            }
-
-            have_trace = true;
-#endif //! defined(PYPY_VERSION)
-        }
-
-        if (!message_error_string.empty()) {
-            if (!have_trace) {
-                result += '\n';
-            }
-            result += "\nMESSAGE UNAVAILABLE DUE TO EXCEPTION: " + message_error_string;
-        }
-
-        return result;
-    }
-
-    std::string const &error_string() const {
-        if (!m_lazy_error_string_completed) {
-            m_lazy_error_string += ": " + format_value_and_trace();
-            m_lazy_error_string_completed = true;
-        }
-        return m_lazy_error_string;
-    }
-
-    void restore() {
-        if (m_restore_called) {
-            pybind11_fail("Internal error: pybind11::detail::error_fetch_and_normalize::restore() "
-                          "called a second time. ORIGINAL ERROR: "
-                          + error_string());
-        }
-        PyErr_Restore(m_type.inc_ref().ptr(), m_value.inc_ref().ptr(), m_trace.inc_ref().ptr());
-        m_restore_called = true;
-    }
+    void restore();
 
     bool matches(handle exc) const {
         return (PyErr_GivenExceptionMatches(m_type.ptr(), exc.ptr()) != 0);
@@ -731,10 +553,6 @@
     mutable bool m_restore_called = false;
 };
 
-inline std::string error_string() {
-    return error_fetch_and_normalize("pybind11::detail::error_string").error_string();
-}
-
 PYBIND11_NAMESPACE_END(detail)
 
 /// Fetch and hold an error which was already set in Python.  An instance of this is typically
@@ -799,39 +617,12 @@
 
 /// Replaces the current Python error indicator with the chosen error, performing a
 /// 'raise from' to indicate that the chosen error was caused by the original error.
-inline void raise_from(PyObject *type, const char *message) {
-    // Based on _PyErr_FormatVFromCause:
-    // https://github.com/python/cpython/blob/467ab194fc6189d9f7310c89937c51abeac56839/Python/errors.c#L405
-    // See https://github.com/pybind/pybind11/pull/2112 for details.
-    PyObject *exc = nullptr, *val = nullptr, *val2 = nullptr, *tb = nullptr;
-
-    assert(PyErr_Occurred());
-    PyErr_Fetch(&exc, &val, &tb);
-    PyErr_NormalizeException(&exc, &val, &tb);
-    if (tb != nullptr) {
-        PyException_SetTraceback(val, tb);
-        Py_DECREF(tb);
-    }
-    Py_DECREF(exc);
-    assert(!PyErr_Occurred());
-
-    PyErr_SetString(type, message);
-
-    PyErr_Fetch(&exc, &val2, &tb);
-    PyErr_NormalizeException(&exc, &val2, &tb);
-    Py_INCREF(val);
-    PyException_SetCause(val2, val);
-    PyException_SetContext(val2, val);
-    PyErr_Restore(exc, val2, tb);
-}
+void raise_from(PyObject *type, const char *message);
 
 /// Sets the current Python error indicator with the chosen error, performing a 'raise from'
 /// from the error contained in error_already_set to indicate that the chosen error was
 /// caused by the original error.
-inline void raise_from(error_already_set &err, PyObject *type, const char *message) {
-    err.restore();
-    raise_from(type, message);
-}
+void raise_from(error_already_set &err, PyObject *type, const char *message);
 
 /** \defgroup python_builtins const_name
     Unless stated otherwise, the following C++ functions behave the same
@@ -2498,40 +2289,6 @@
 #endif
 };
 
-/// @cond DUPLICATE
-inline memoryview memoryview::from_buffer(void *ptr,
-                                          ssize_t itemsize,
-                                          const char *format,
-                                          detail::any_container<ssize_t> shape,
-                                          detail::any_container<ssize_t> strides,
-                                          bool readonly) {
-    size_t ndim = shape->size();
-    if (ndim != strides->size()) {
-        pybind11_fail("memoryview: shape length doesn't match strides length");
-    }
-    ssize_t size = ndim != 0u ? 1 : 0;
-    for (size_t i = 0; i < ndim; ++i) {
-        size *= (*shape)[i];
-    }
-    Py_buffer view;
-    view.buf = ptr;
-    view.obj = nullptr;
-    view.len = size * itemsize;
-    view.readonly = static_cast<int>(readonly);
-    view.itemsize = itemsize;
-    view.format = const_cast<char *>(format);
-    view.ndim = static_cast<int>(ndim);
-    view.shape = shape->data();
-    view.strides = strides->data();
-    view.suboffsets = nullptr;
-    view.internal = nullptr;
-    PyObject *obj = PyMemoryView_FromBuffer(&view);
-    if (!obj) {
-        throw error_already_set();
-    }
-    return memoryview(object(obj, stolen_t{}));
-}
-/// @endcond
 /// @} pytypes
 
 /// \addtogroup python_builtins
@@ -2724,3 +2481,7 @@
 
 PYBIND11_NAMESPACE_END(detail)
 PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)
+
+#ifndef PYBIND11_PRECOMPILED
+#    include "pytypes-inl.h" // IWYU pragma: export
+#endif
diff --git a/src/internals.cpp b/src/internals.cpp
new file mode 100644
index 0000000..86a3549
--- /dev/null
+++ b/src/internals.cpp
@@ -0,0 +1,10 @@
+// Copyright (c) 2025 The Pybind Development Team.
+// All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+#if !defined(PYBIND11_PRECOMPILED)
+#    error "pybind11 library sources must be compiled with PYBIND11_PRECOMPILED defined."
+#endif
+
+#include <pybind11/detail/internals-inl.h>
+#include <pybind11/pybind11.h>
diff --git a/src/pybind11_combined.cpp b/src/pybind11_combined.cpp
new file mode 100644
index 0000000..751b0b1
--- /dev/null
+++ b/src/pybind11_combined.cpp
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 The Pybind Development Team.
+// All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// Single-TU build of the pybind11 library sources, for build systems that prefer adding
+// one file over one file per header (e.g. setuptools). Compile this file (and every TU
+// that includes pybind11) with PYBIND11_PRECOMPILED defined. Keep in sync with the list
+// of -inl.h files; the CMake path compiles the individual src/*.cpp files instead.
+
+#if !defined(PYBIND11_PRECOMPILED)
+#    error "pybind11 library sources must be compiled with PYBIND11_PRECOMPILED defined."
+#endif
+
+#include <pybind11/detail/internals-inl.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/pytypes-inl.h>
diff --git a/src/pytypes.cpp b/src/pytypes.cpp
new file mode 100644
index 0000000..c0d655d
--- /dev/null
+++ b/src/pytypes.cpp
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 The Pybind Development Team.
+// All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+#if !defined(PYBIND11_PRECOMPILED)
+#    error "pybind11 library sources must be compiled with PYBIND11_PRECOMPILED defined."
+#endif
+
+// pybind11.h first: the -inl.h definitions instantiate templates (e.g. handle::cast<T>)
+// whose definitions live in other headers.
+#include <pybind11/pybind11.h>
+#include <pybind11/pytypes-inl.h>
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d6415b9..c23e766 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -71,6 +71,16 @@
 option(DOWNLOAD_EIGEN "Download EIGEN" OFF)
 option(PYBIND11_CUDA_TESTS "Enable building CUDA tests" OFF)
 option(PYBIND11_TEST_SMART_HOLDER "Change the default to smart holder" OFF)
+option(PYBIND11_TEST_PRECOMPILE "Build the test modules against the precompiled library" OFF)
+
+if(PYBIND11_TEST_PRECOMPILE)
+  # Picked up by every pybind11_add_module() call below; any definition missing
+  # from the precompiled library shows up as a link error across the test modules.
+  set(PYBIND11_PRECOMPILE ON)
+endif()
+# These modules redefine PYBIND11_INTERNALS_VERSION inside their own TU, so they
+# cannot share the precompiled library.
+set(PYBIND11_NO_PRECOMPILE_TARGETS exo_planet_pybind11 cross_module_gil_utils)
 set(PYBIND11_TEST_OVERRIDE
     ""
     CACHE STRING "Tests from ;-separated list of *.cpp files will be built instead of all tests")
@@ -483,8 +493,14 @@
     set_property(SOURCE ${target}.cpp PROPERTY LANGUAGE CUDA)
   endif()
 
+  set(no_precompile_arg "")
+  if("${target}" IN_LIST PYBIND11_NO_PRECOMPILE_TARGETS)
+    set(no_precompile_arg NO_PRECOMPILE)
+  endif()
+
   # Create the binding library
-  pybind11_add_module(${target} THIN_LTO ${target}.cpp ${test_files} ${PYBIND11_HEADERS})
+  pybind11_add_module(${target} THIN_LTO ${no_precompile_arg} ${target}.cpp ${test_files}
+                      ${PYBIND11_HEADERS})
   pybind11_enable_warnings(${target})
 
   if(NOT CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_CURRENT_BINARY_DIR)
diff --git a/tests/extra_python_package/test_files.py b/tests/extra_python_package/test_files.py
index 164611d..b02892a 100644
--- a/tests/extra_python_package/test_files.py
+++ b/tests/extra_python_package/test_files.py
@@ -62,6 +62,7 @@
     "include/pybind11/operators.h",
     "include/pybind11/options.h",
     "include/pybind11/pybind11.h",
+    "include/pybind11/pytypes-inl.h",
     "include/pybind11/pytypes.h",
     "include/pybind11/subinterpreter.h",
     "include/pybind11/stl.h",
@@ -90,6 +91,7 @@
     "include/pybind11/detail/function_ref.h",
     "include/pybind11/detail/holder_caster_foreign_helpers.h",
     "include/pybind11/detail/init.h",
+    "include/pybind11/detail/internals-inl.h",
     "include/pybind11/detail/internals.h",
     "include/pybind11/detail/native_enum_data.h",
     "include/pybind11/detail/pybind11_namespace_macros.h",
@@ -126,6 +128,14 @@
     "share/pkgconfig/pybind11.pc",
 }
 
+sdist_src_files = {
+    "src/internals.cpp",
+    "src/pybind11_combined.cpp",
+    "src/pytypes.cpp",
+}
+
+src_files = {f"share/pybind11/{n}" for n in sdist_src_files}
+
 py_files = {
     "__init__.py",
     "__main__.py",
@@ -138,7 +148,7 @@
 }
 
 headers = main_headers | conduit_headers | detail_headers | eigen_headers | stl_headers
-generated_files = cmake_files | pkgconfig_files
+generated_files = cmake_files | pkgconfig_files | src_files
 all_files = headers | generated_files | py_files
 
 sdist_files = {
@@ -204,7 +214,7 @@
         pyproject_toml = read_tz_file(tar, "pyproject.toml")
         pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")
 
-    files = headers | sdist_files
+    files = headers | sdist_src_files | sdist_files
     assert files <= simpler
 
     assert b'name = "pybind11"' in pyproject_toml
@@ -241,7 +251,7 @@
         pyproject_toml = read_tz_file(tar, "pyproject.toml")
         pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")
 
-    files = headers | sdist_files
+    files = headers | sdist_src_files | sdist_files
     assert files <= simpler
 
     assert b'name = "pybind11-global"' in pyproject_toml
diff --git a/tests/test_cmake_build/CMakeLists.txt b/tests/test_cmake_build/CMakeLists.txt
index a4d2544..33a78a6 100644
--- a/tests/test_cmake_build/CMakeLists.txt
+++ b/tests/test_cmake_build/CMakeLists.txt
@@ -69,6 +69,7 @@
 
 pybind11_add_build_test(subdirectory_function)
 pybind11_add_build_test(subdirectory_target)
+pybind11_add_build_test(subdirectory_precompile)
 if("${PYTHON_MODULE_EXTENSION}" MATCHES "pypy"
    OR "${Python_INTERPRETER_ID}" STREQUAL "PyPy"
    OR "${PYTHON_MODULE_EXTENSION}" MATCHES "graalpy")
@@ -86,6 +87,7 @@
     pybind11_add_build_test(installed_function INSTALL)
   endif()
   pybind11_add_build_test(installed_target INSTALL)
+  pybind11_add_build_test(installed_precompile INSTALL)
   if(NOT
      ("${PYTHON_MODULE_EXTENSION}" MATCHES "pypy"
       OR "${Python_INTERPRETER_ID}" STREQUAL "PyPy"
diff --git a/tests/test_cmake_build/installed_precompile/CMakeLists.txt b/tests/test_cmake_build/installed_precompile/CMakeLists.txt
new file mode 100644
index 0000000..268aebc
--- /dev/null
+++ b/tests/test_cmake_build/installed_precompile/CMakeLists.txt
@@ -0,0 +1,28 @@
+cmake_minimum_required(VERSION 3.15...4.2)
+
+project(test_installed_precompile CXX)
+
+find_package(pybind11 CONFIG REQUIRED)
+message(STATUS "Found pybind11 v${pybind11_VERSION}: ${pybind11_INCLUDE_DIRS}")
+
+pybind11_add_module(test_installed_precompile PRECOMPILE ../main.cpp)
+set_target_properties(test_installed_precompile PROPERTIES OUTPUT_NAME test_cmake_build)
+
+if(DEFINED Python_EXECUTABLE)
+  set(_Python_EXECUTABLE "${Python_EXECUTABLE}")
+elseif(DEFINED PYTHON_EXECUTABLE)
+  set(_Python_EXECUTABLE "${PYTHON_EXECUTABLE}")
+else()
+  message(FATAL_ERROR "No Python executable defined (should not be possible at this stage)")
+endif()
+
+add_custom_target(
+  check_installed_precompile
+  ${CMAKE_COMMAND}
+  -E
+  env
+  PYTHONPATH=$<TARGET_FILE_DIR:test_installed_precompile>
+  ${_Python_EXECUTABLE}
+  ${PROJECT_SOURCE_DIR}/../test.py
+  ${PROJECT_NAME}
+  DEPENDS test_installed_precompile)
diff --git a/tests/test_cmake_build/subdirectory_precompile/CMakeLists.txt b/tests/test_cmake_build/subdirectory_precompile/CMakeLists.txt
new file mode 100644
index 0000000..02e8c0f
--- /dev/null
+++ b/tests/test_cmake_build/subdirectory_precompile/CMakeLists.txt
@@ -0,0 +1,32 @@
+cmake_minimum_required(VERSION 3.15...4.2)
+
+project(test_subdirectory_precompile CXX)
+
+# Allow PYTHON_EXECUTABLE if in FINDPYTHON mode and building pybind11's tests
+# (makes transition easier while we support both modes).
+if(DEFINED PYTHON_EXECUTABLE AND NOT DEFINED Python_EXECUTABLE)
+  set(Python_EXECUTABLE "${PYTHON_EXECUTABLE}")
+endif()
+
+add_subdirectory("${pybind11_SOURCE_DIR}" pybind11)
+pybind11_add_module(test_subdirectory_precompile PRECOMPILE ../main.cpp)
+set_target_properties(test_subdirectory_precompile PROPERTIES OUTPUT_NAME test_cmake_build)
+
+if(DEFINED Python_EXECUTABLE)
+  set(_Python_EXECUTABLE "${Python_EXECUTABLE}")
+elseif(DEFINED PYTHON_EXECUTABLE)
+  set(_Python_EXECUTABLE "${PYTHON_EXECUTABLE}")
+else()
+  message(FATAL_ERROR "No Python executable defined (should not be possible at this stage)")
+endif()
+
+add_custom_target(
+  check_subdirectory_precompile
+  ${CMAKE_COMMAND}
+  -E
+  env
+  PYTHONPATH=$<TARGET_FILE_DIR:test_subdirectory_precompile>
+  ${_Python_EXECUTABLE}
+  ${PROJECT_SOURCE_DIR}/../test.py
+  ${PROJECT_NAME}
+  DEPENDS test_subdirectory_precompile)
diff --git a/tools/pybind11Common.cmake b/tools/pybind11Common.cmake
index d75fb67..0073d36 100644
--- a/tools/pybind11Common.cmake
+++ b/tools/pybind11Common.cmake
@@ -466,3 +466,78 @@
       COMMAND ${CMAKE_STRIP} ${x_opt} $<TARGET_FILE:${target_name}>)
   endif()
 endfunction()
+
+# -fvisibility=hidden is required to allow multiple modules compiled against
+# different pybind versions to work properly, and for some features (e.g.
+# py::module_local).  We force it on everything inside the `pybind11`
+# namespace; also turning it on for a pybind module compilation here avoids
+# potential warnings or issues from having mixed hidden/non-hidden types.
+function(_pybind11_default_hidden_visibility target_name)
+  if(NOT DEFINED CMAKE_CXX_VISIBILITY_PRESET)
+    set_target_properties(${target_name} PROPERTIES CXX_VISIBILITY_PRESET "hidden")
+  endif()
+
+  if(NOT DEFINED CMAKE_CUDA_VISIBILITY_PRESET)
+    set_target_properties(${target_name} PROPERTIES CUDA_VISIBILITY_PRESET "hidden")
+  endif()
+endfunction()
+
+# --------------------- pybind11_precompile -------------------------
+
+# Create the pybind11::precompiled static library (once per build tree). It is
+# built from the consumer's project with the consumer's flags; each extension
+# module links its own copy, preserving pybind11's per-module state. Modules
+# using it must compile with PYBIND11_PRECOMPILED, which the PUBLIC compile
+# definition below provides automatically.
+function(pybind11_precompile)
+  if(TARGET pybind11_precompiled)
+    return()
+  endif()
+
+  if(PYBIND11_NOPYTHON)
+    message(FATAL_ERROR "pybind11_precompile requires Python headers; it cannot be used "
+                        "with PYBIND11_NOPYTHON")
+  endif()
+
+  if(NOT pybind11_SRC_DIR OR NOT EXISTS "${pybind11_SRC_DIR}")
+    message(FATAL_ERROR "pybind11 library sources not found (pybind11_SRC_DIR: "
+                        "'${pybind11_SRC_DIR}')")
+  endif()
+
+  # CONFIGURE_DEPENDS: an in-place pybind11 upgrade can add src files; a stale
+  # list would fail with confusing undefined symbols.
+  file(GLOB _pybind11_precompile_sources CONFIGURE_DEPENDS "${pybind11_SRC_DIR}/*.cpp")
+  list(FILTER _pybind11_precompile_sources EXCLUDE REGEX "pybind11_combined\\.cpp$")
+
+  add_library(pybind11_precompiled STATIC EXCLUDE_FROM_ALL ${_pybind11_precompile_sources})
+  add_library(pybind11::precompiled ALIAS pybind11_precompiled)
+  target_compile_definitions(pybind11_precompiled PUBLIC PYBIND11_PRECOMPILED)
+  # pybind11::module (not just pybind11::pybind11): the library must compile with the
+  # interpreter's ABI macros (e.g. Py_GIL_DISABLED, which FindPython attaches to
+  # Python::Module); on free-threaded Windows they select the correct autolink library.
+  target_link_libraries(
+    pybind11_precompiled
+    PUBLIC pybind11::headers
+    PRIVATE pybind11::module)
+  set_target_properties(pybind11_precompiled PROPERTIES POSITION_INDEPENDENT_CODE ON)
+  _pybind11_default_hidden_visibility(pybind11_precompiled)
+  if(NOT DEFINED CMAKE_VISIBILITY_INLINES_HIDDEN)
+    set_target_properties(pybind11_precompiled PROPERTIES VISIBILITY_INLINES_HIDDEN ON)
+  endif()
+  if(MSVC)
+    target_link_libraries(pybind11_precompiled PRIVATE pybind11::windows_extras)
+  endif()
+
+  # The first caller creates the library, so its directory scope supplies the
+  # library's flags and C++ standard; record it to make that visible.
+  message(STATUS "pybind11::precompiled created in ${CMAKE_CURRENT_SOURCE_DIR}")
+endfunction()
+
+# Link pybind11::precompiled when the PRECOMPILE keyword or the global
+# PYBIND11_PRECOMPILE variable requests it, unless NO_PRECOMPILE opts out.
+function(_pybind11_maybe_precompile target_name precompile no_precompile)
+  if((precompile OR PYBIND11_PRECOMPILE) AND NOT no_precompile)
+    pybind11_precompile()
+    target_link_libraries(${target_name} PRIVATE pybind11::precompiled)
+  endif()
+endfunction()
diff --git a/tools/pybind11Config.cmake.in b/tools/pybind11Config.cmake.in
index f52b2fb..abcd43e 100644
--- a/tools/pybind11Config.cmake.in
+++ b/tools/pybind11Config.cmake.in
@@ -211,6 +211,9 @@
 # This will be relative unless explicitly set as absolute
 set(pybind11_INCLUDE_DIR "@pybind11_INCLUDEDIR@")
 
+# Location of the library sources for the opt-in precompiled mode
+set(pybind11_SRC_DIR "@pybind11_SRCDIR@")
+
 set(pybind11_LIBRARY "")
 set(pybind11_DEFINITIONS USING_pybind11)
 set(pybind11_VERSION_TYPE "@pybind11_VERSION_TYPE@")
diff --git a/tools/pybind11NewTools.cmake b/tools/pybind11NewTools.cmake
index b0fe207..175268d 100644
--- a/tools/pybind11NewTools.cmake
+++ b/tools/pybind11NewTools.cmake
@@ -255,8 +255,10 @@
 # WITHOUT_SOABI and WITH_SOABI will disable the custom extension handling used by pybind11.
 # WITH_SOABI is passed on to python_add_library.
 function(pybind11_add_module target_name)
-  cmake_parse_arguments(PARSE_ARGV 1 ARG
-                        "STATIC;SHARED;MODULE;THIN_LTO;OPT_SIZE;NO_EXTRAS;WITHOUT_SOABI" "" "")
+  cmake_parse_arguments(
+    PARSE_ARGV 1 ARG
+    "STATIC;SHARED;MODULE;THIN_LTO;OPT_SIZE;NO_EXTRAS;WITHOUT_SOABI;PRECOMPILE;NO_PRECOMPILE" ""
+    "")
 
   if(ARG_STATIC)
     set(lib_type STATIC)
@@ -282,18 +284,9 @@
     target_link_libraries(${target_name} PRIVATE pybind11::embed)
   endif()
 
-  # -fvisibility=hidden is required to allow multiple modules compiled against
-  # different pybind versions to work properly, and for some features (e.g.
-  # py::module_local).  We force it on everything inside the `pybind11`
-  # namespace; also turning it on for a pybind module compilation here avoids
-  # potential warnings or issues from having mixed hidden/non-hidden types.
-  if(NOT DEFINED CMAKE_CXX_VISIBILITY_PRESET)
-    set_target_properties(${target_name} PROPERTIES CXX_VISIBILITY_PRESET "hidden")
-  endif()
+  _pybind11_maybe_precompile(${target_name} "${ARG_PRECOMPILE}" "${ARG_NO_PRECOMPILE}")
 
-  if(NOT DEFINED CMAKE_CUDA_VISIBILITY_PRESET)
-    set_target_properties(${target_name} PROPERTIES CUDA_VISIBILITY_PRESET "hidden")
-  endif()
+  _pybind11_default_hidden_visibility(${target_name})
 
   # If we don't pass a WITH_SOABI or WITHOUT_SOABI, use our own default handling of extensions
   if(NOT ARG_WITHOUT_SOABI AND NOT "WITH_SOABI" IN_LIST ARG_UNPARSED_ARGUMENTS)
diff --git a/tools/pybind11Tools.cmake b/tools/pybind11Tools.cmake
index 81faee7..9a7e5c6 100644
--- a/tools/pybind11Tools.cmake
+++ b/tools/pybind11Tools.cmake
@@ -139,7 +139,8 @@
 #                     [NO_EXTRAS] [THIN_LTO] [OPT_SIZE] source1 [source2 ...])
 #
 function(pybind11_add_module target_name)
-  set(options "MODULE;SHARED;EXCLUDE_FROM_ALL;NO_EXTRAS;SYSTEM;THIN_LTO;OPT_SIZE")
+  set(options
+      "MODULE;SHARED;EXCLUDE_FROM_ALL;NO_EXTRAS;SYSTEM;THIN_LTO;OPT_SIZE;PRECOMPILE;NO_PRECOMPILE")
   cmake_parse_arguments(ARG "${options}" "" "" ${ARGN})
 
   if(ARG_MODULE AND ARG_SHARED)
@@ -160,6 +161,8 @@
 
   target_link_libraries(${target_name} PRIVATE pybind11::module)
 
+  _pybind11_maybe_precompile(${target_name} "${ARG_PRECOMPILE}" "${ARG_NO_PRECOMPILE}")
+
   if(ARG_SYSTEM)
     message(
       STATUS
@@ -169,18 +172,7 @@
 
   pybind11_extension(${target_name})
 
-  # -fvisibility=hidden is required to allow multiple modules compiled against
-  # different pybind versions to work properly, and for some features (e.g.
-  # py::module_local).  We force it on everything inside the `pybind11`
-  # namespace; also turning it on for a pybind module compilation here avoids
-  # potential warnings or issues from having mixed hidden/non-hidden types.
-  if(NOT DEFINED CMAKE_CXX_VISIBILITY_PRESET)
-    set_target_properties(${target_name} PROPERTIES CXX_VISIBILITY_PRESET "hidden")
-  endif()
-
-  if(NOT DEFINED CMAKE_CUDA_VISIBILITY_PRESET)
-    set_target_properties(${target_name} PROPERTIES CUDA_VISIBILITY_PRESET "hidden")
-  endif()
+  _pybind11_default_hidden_visibility(${target_name})
 
   if(ARG_NO_EXTRAS)
     return()