diff --git a/scipy/optimize/_minpack_py.py b/scipy/optimize/_minpack_py.py
index 8c0564c06bbe..8cfce8aae21b 100644
--- a/scipy/optimize/_minpack_py.py
+++ b/scipy/optimize/_minpack_py.py
@@ -964,12 +964,21 @@ def curve_fit(f, xdata, ydata, p0=None, sigma=None, absolute_sigma=False,
             xdata = xdata[..., ~has_nan]
             ydata = ydata[~has_nan]

+            # Also omit the corresponding entries from sigma
+            if sigma is not None:
+                sigma = np.asarray(sigma)
+                if sigma.ndim == 1:
+                    sigma = sigma[~has_nan]
+                elif sigma.ndim == 2:
+                    sigma = sigma[~has_nan, :]
+                    sigma = sigma[:, ~has_nan]
+
     # Determine type of sigma
     if sigma is not None:
         sigma = np.asarray(sigma)

         # if 1-D or a scalar, sigma are errors, define transform = 1/sigma
-        if sigma.size == 1 or sigma.shape == (ydata.size, ):
+        if sigma.size == 1 or sigma.shape == (ydata.size,):
             transform = 1.0 / sigma
         # if 2-D, sigma is the covariance matrix,
         # define transform = L such that L L^T = C

diff --git a/scipy/optimize/tests/test_minpack.py b/scipy/optimize/tests/test_minpack.py
index dce5776d27cb..ef107c5692c2 100644
--- a/scipy/optimize/tests/test_minpack.py
+++ b/scipy/optimize/tests/test_minpack.py
@@ -811,6 +811,61 @@ def test_maxfev_and_bounds(self):
         assert_allclose(popt1, 2, atol=1e-14)
         assert_allclose(popt2, 2, atol=1e-14)

+    @pytest.mark.parametrize("sigma_dim", [0, 1, 2])
+    def test_curvefit_omitnan(self, sigma_dim):
+        def exponential(x, a, b):
+            return b * np.exp(a * x)
+
+        rng = np.random.default_rng(578285731148908)
+        N = 100
+        x = np.linspace(1, 10, N)
+        y = exponential(x, 0.2, 0.5)
+
+        if (sigma_dim == 0):
+            sigma = 0.05
+            y += rng.normal(0, sigma, N)
+
+        elif (sigma_dim == 1):
+            sigma = x * 0.05
+            y += rng.normal(0, sigma, N)
+
+        elif (sigma_dim == 2):
+            # The covariance matrix must be symmetric positive-semidefinite
+            a = rng.normal(0, 2, (N, N))
+            sigma = a @ a.T
+            y += rng.multivariate_normal(np.zeros_like(x), sigma)
+        else:
+            assert False, "The sigma must be a scalar, 1D array or 2D array."
+
+        p0 = [0.1, 1.0]
+
+        # Choose indices to place NaNs.
+        i_x = rng.integers(N, size=5)
+        i_y = rng.integers(N, size=5)
+
+        # Add NaNs and compute result using `curve_fit`
+        x[i_x] = np.nan
+        y[i_y] = np.nan
+        res_opt, res_cov = curve_fit(exponential, x, y, p0=p0, sigma=sigma,
+                                     nan_policy="omit")
+
+        # Manually remove elements that should be eliminated, and
+        # calculate reference using `curve_fit`
+        i_delete = np.unique(np.concatenate((i_x, i_y)))
+        x = np.delete(x, i_delete, axis=0)
+        y = np.delete(y, i_delete, axis=0)
+
+        sigma = np.asarray(sigma)
+        if sigma.ndim == 1:
+            sigma = np.delete(sigma, i_delete)
+        elif sigma.ndim == 2:
+            sigma = np.delete(sigma, i_delete, axis=0)
+            sigma = np.delete(sigma, i_delete, axis=1)
+        ref_opt, ref_cov = curve_fit(exponential, x, y, p0=p0, sigma=sigma)
+
+        assert_allclose(res_opt, ref_opt, atol=1e-14)
+        assert_allclose(res_cov, ref_cov, atol=1e-14)
+
     def test_curvefit_simplecovariance(self):

         def func(x, a, b):

BUG: `optimize.curve_fit` with `nan_policy="omit"` fails to handle sigma properly
### Describe your issue.

Hello,
I have encountered an issue with curve_fit. It is bypassable, but I'd say it is unexpected behaviour. Generally speaking, it occurs when both of the following is true:
-- I have xdata or ydata with NaNs and use nan_policy = "omit" to handle them;
-- I have specified sigma.
In this case, I get ValueError "Sigma has incorrect shape" in optimize._minpack_py.py despite sigma being of the same size as xdata and ydata.

I checked what is going in _minpack_py.py and figured out the following.
It occurs because "nan_policy = omit" leads to the removal of NaN indices from xdata and ydata (lines 960-967), but not from sigma. Then, after line 969, the shape of sigma is checked by comparing it with ydata. And even if sigma and ydata were originally of the same size, they end up to be different after ydata truncation, throwing an error.

So, to bypass this problem, one has either to remove indices corresponding NaNs in xdata and ydata from sigma, or not using nan_policy = "omit" at all and remove NaN indices manually. I suggest improving nan_policy = "omit" in a way that it would remove the corresponding indices from sigma, if needed.

Sorry if my bug report is incorrect, I am first time doing this.

### Reproducing Code Example

```python
import numpy as np
from scipy.optimize import curve_fit

def exponential(x, a, b):
	return b * np.exp(a * x)

np.random.seed(0)
N = 90
x = np.linspace(1, 10, N, endpoint = False)
sigma = x * 0.05
y = exponential(x, 0.2, 0.5) + np.random.normal(0, sigma, N)

p0 = [0.1, 1.0]

# without nans, works perfect
popt, pcov = curve_fit(exponential, x, y, p0 = p0, sigma = sigma)
perr = np.diag(np.sqrt(pcov))
print("without nans:")
print(f"popt: {popt}")
print(f"perr: {perr}")

# with nans, have to set nan_policy = "omit"...
x[11] = np.nan
y[22] = np.nan
popt, pcov = curve_fit(exponential, x, y, p0 = p0, nan_policy = "omit")
perr = np.diag(np.sqrt(pcov))
print("with nans:")
print(f"popt: {popt}")
print(f"perr: {perr}")

#...but when using sigma, it says "sigma has incorrect shape"
x[11] = np.nan
y[22] = np.nan
popt, pcov = curve_fit(exponential, x, y, p0 = p0, sigma = sigma, nan_policy = "omit")
```

### Error message

```shell
Traceback (most recent call last):
  File "/root/Science/Spiral_shape_analysis/fit_results/../spiralcutter_advanced/scipy_test.py", line 34, in <module>
    popt, pcov = curve_fit(exponential, x, y, p0 = p0, sigma = sigma, nan_policy = "omit")
  File "/usr/local/lib/python3.10/dist-packages/scipy/optimize/_minpack_py.py", line 985, in curve_fit
    raise ValueError("`sigma` has incorrect shape.")
ValueError: `sigma` has incorrect shape.
```

### SciPy/NumPy/Python version and system information

```shell
>>> print(scipy.__version__, numpy.__version__, sys.version_info)
1.14.1 2.1.2 sys.version_info(major=3, minor=10, micro=12, releaselevel='final', serial=0)
>>> scipy.show_config()
Build Dependencies:
  blas:
    detection method: pkgconfig
    found: true
    include directory: /opt/_internal/cpython-3.10.14/lib/python3.10/site-packages/scipy_openblas32/include
    lib directory: /opt/_internal/cpython-3.10.14/lib/python3.10/site-packages/scipy_openblas32/lib
    name: scipy-openblas
    openblas configuration: OpenBLAS 0.3.27.dev DYNAMIC_ARCH NO_AFFINITY Zen MAX_THREADS=64
    pc file directory: /project
    version: 0.3.27.dev
  lapack:
    detection method: pkgconfig
    found: true
    include directory: /opt/_internal/cpython-3.10.14/lib/python3.10/site-packages/scipy_openblas32/include
    lib directory: /opt/_internal/cpython-3.10.14/lib/python3.10/site-packages/scipy_openblas32/lib
    name: scipy-openblas
    openblas configuration: OpenBLAS 0.3.27.dev DYNAMIC_ARCH NO_AFFINITY Zen MAX_THREADS=64
    pc file directory: /project
    version: 0.3.27.dev
  pybind11:
    detection method: config-tool
    include directory: unknown
    name: pybind11
    version: 2.12.0
Compilers:
  c:
    commands: cc
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  c++:
    commands: c++
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 3.0.11
  fortran:
    commands: gfortran
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  pythran:
    include directory: ../../tmp/pip-build-env-h_xz8lfs/overlay/lib/python3.10/site-packages/pythran
    version: 0.16.1
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: false
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /opt/python/cp310-cp310/bin/python
  version: '3.10'
```

Oops, thanks for reporting!

@AtsushiSakai Looks like we missed this. Do you have a moment to submit a PR?

@Vallastro Just to confirm, this would also be a problem when `sigma` is 2D. In that case, we would remove both the rows and columns where there are NaNs in either `x` or `y`?
> Just to confirm, this would also be a problem when `sigma` is 2D. In that case, we would remove both the rows and columns where there are NaNs in either `x` or `y`?

I guess, yes. In this case, if `ydata[i]` is invalidated, then for any `j` covariation between `xdata[i]` and `xdata[j]`, represented in `sigma[i, j]` and `sigma[j, i]` has no sense. And if `N` elements of `ydata` out of original `M` are remaining after removing NaNs, then removing columns and rows from `sigma` will make it `N` x `N` from `M` x `M`, which is expected.
@mdhaber Is this issue accepting PRs? If so, could you please point me to the files that need to be changed? I would love to contribute.


diff --git a/scipy/integrate/_quadrature.py b/scipy/integrate/_quadrature.py
index 4f423da63895..65faaaa79b85 100644
--- a/scipy/integrate/_quadrature.py
+++ b/scipy/integrate/_quadrature.py
@@ -136,11 +136,16 @@ def trapezoid(y, x=None, dx=1.0, axis=-1):
         d = dx
     else:
         x = _asarray(x, xp=xp, subok=True)
-        # reshape to correct shape
-        shape = [1] * y.ndim
-        shape[axis] = y.shape[axis]
-        x = xp.reshape(x, shape)
-        d = x[tuple(slice1)] - x[tuple(slice2)]
+        if x.ndim == 1:
+            d = x[1:] - x[:-1]
+            # make d broadcastable to y
+            slice3 = [None] * nd
+            slice3[axis] = slice(None)
+            d = d[tuple(slice3)]
+        else:
+            # if x is n-D it should be broadcastable to y
+            x = xp.broadcast_to(x, y.shape)
+            d = x[tuple(slice1)] - x[tuple(slice2)]
     try:
         ret = xp.sum(
             d * (y[tuple(slice1)] + y[tuple(slice2)]) / 2.0,

diff --git a/scipy/integrate/tests/test_quadrature.py b/scipy/integrate/tests/test_quadrature.py
index e49ded996a2e..ec554d3b0bc8 100644
--- a/scipy/integrate/tests/test_quadrature.py
+++ b/scipy/integrate/tests/test_quadrature.py
@@ -309,14 +309,6 @@ def test_ndim(self, xp):
         r = trapezoid(q, x=z[None, None,:], axis=2)
         xp_assert_close(r, qz)

-        # n-d `x` but not the same as `y`
-        r = trapezoid(q, x=xp.reshape(x[:, None, None], (3, 1)), axis=0)
-        xp_assert_close(r, qx)
-        r = trapezoid(q, x=xp.reshape(y[None,:, None], (8, 1)), axis=1)
-        xp_assert_close(r, qy)
-        r = trapezoid(q, x=xp.reshape(z[None, None,:], (13, 1)), axis=2)
-        xp_assert_close(r, qz)
-
         # 1-d `x`
         r = trapezoid(q, x=x, axis=0)
         xp_assert_close(r, qx)
@@ -325,6 +317,33 @@ def test_ndim(self, xp):
         r = trapezoid(q, x=z, axis=2)
         xp_assert_close(r, qz)

+    @skip_xp_backends('jax.numpy',
+                      reasons=["JAX arrays do not support item assignment"])
+    @pytest.mark.usefixtures("skip_xp_backends")
+    def test_gh21908(self, xp):
+        # extended testing for n-dim arrays
+        x = xp.reshape(xp.linspace(0, 29, 30), (3, 10))
+        y = xp.reshape(xp.linspace(0, 29, 30), (3, 10))
+
+        out0 = xp.linspace(200, 380, 10)
+        xp_assert_close(trapezoid(y, x=x, axis=0), out0)
+        xp_assert_close(trapezoid(y, x=xp.asarray([0, 10., 20.]), axis=0), out0)
+        # x needs to be broadcastable against y
+        xp_assert_close(
+            trapezoid(y, x=xp.asarray([0, 10., 20.])[:, None], axis=0),
+            out0
+        )
+        with pytest.raises(Exception):
+            # x is not broadcastable against y
+            trapezoid(y, x=xp.asarray([0, 10., 20.])[None, :], axis=0)
+
+        out1 = xp.asarray([ 40.5, 130.5, 220.5])
+        xp_assert_close(trapezoid(y, x=x, axis=1), out1)
+        xp_assert_close(
+            trapezoid(y, x=xp.linspace(0, 9, 10), axis=1),
+            out1
+        )
+
     @skip_xp_invalid_arg
     def test_masked(self, xp):
         # Testing that masked arrays behave as if the function is 0 where

BUG: integrate.trapezoid: broadcasting failure after #21524
### Describe your issue.

`scipy.integrate.trapezoid` fails when 2D arrays of identical shape are passed as `x` and `y` arguments.

This failure started in recent nightlies, probably from https://github.com/scipy/scipy/pull/21524.

The same example works with scipy 1.14.1 and earlier.

@Armavica @lucascolley

### Reproducing Code Example

```python
In [1]: import numpy as np, scipy

In [2]: x = np.random.randn(3, 10)

In [3]: scipy.integrate.trapezoid(x, x)
```

### Error message

```shell
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[3], line 1
----> 1 scipy.integrate.trapezoid(x, x)

File ~/.pyenv/versions/3.12.3/envs/py3.12.3/lib/python3.12/site-packages/scipy/integrate/_quadrature.py:142, in trapezoid(y, x, dx, axis)
    140     shape = [1] * y.ndim
    141     shape[axis] = y.shape[axis]
--> 142     x = xp.reshape(x, shape)
    143     d = x[tuple(slice1)] - x[tuple(slice2)]
    144 try:

File ~/.pyenv/versions/3.12.3/envs/py3.12.3/lib/python3.12/site-packages/scipy/_lib/array_api_compat/_internal.py:28, in get_xp.<locals>.inner.<locals>.wrapped_f(*args, **kwargs)
     26 @wraps(f)
     27 def wrapped_f(*args, **kwargs):
---> 28     return f(*args, xp=xp, **kwargs)

File ~/.pyenv/versions/3.12.3/envs/py3.12.3/lib/python3.12/site-packages/scipy/_lib/array_api_compat/common/_aliases.py:382, in reshape(x, shape, xp, copy, **kwargs)
    380     y.shape = shape
    381     return y
--> 382 return xp.reshape(x, shape, **kwargs)

File ~/.pyenv/versions/3.12.3/envs/py3.12.3/lib/python3.12/site-packages/numpy/core/fromnumeric.py:285, in reshape(a, newshape, order)
    200 @array_function_dispatch(_reshape_dispatcher)
    201 def reshape(a, newshape, order='C'):
    202     """
    203     Gives a new shape to an array without changing its data.
    204
   (...)
    283            [5, 6]])
    284     """
--> 285     return _wrapfunc(a, 'reshape', newshape, order=order)

File ~/.pyenv/versions/3.12.3/envs/py3.12.3/lib/python3.12/site-packages/numpy/core/fromnumeric.py:59, in _wrapfunc(obj, method, *args, **kwds)
     56     return _wrapit(obj, method, *args, **kwds)
     58 try:
---> 59     return bound(*args, **kwds)
     60 except TypeError:
     61     # A TypeError occurs if the object does have such a method in its
     62     # class, but its signature is not identical to that of NumPy's. This
   (...)
     66     # Call _wrapit from within the except clause to ensure a potential
     67     # exception has a traceback chain.
     68     return _wrapit(obj, method, *args, **kwds)

ValueError: cannot reshape array of size 30 into shape (1,10)
```

### SciPy/NumPy/Python version and system information

```shell
1.15.0.dev0+git20241118.0ea90cf 1.26.4 sys.version_info(major=3, minor=12, micro=3, releaselevel='final', serial=0)
Build Dependencies:
  blas:
    detection method: extraframeworks
    found: true
    include directory: unknown
    lib directory: unknown
    name: Accelerate
    openblas configuration: unknown
    pc file directory: unknown
    version: unknown
  lapack:
    detection method: extraframeworks
    found: true
    include directory: unknown
    lib directory: unknown
    name: Accelerate
    openblas configuration: unknown
    pc file directory: unknown
    version: unknown
  pybind11:
    detection method: config-tool
    include directory: unknown
    name: pybind11
    version: 2.13.6
Compilers:
  c:
    commands: cc
    linker: ld64
    name: clang
    version: 15.0.0
  c++:
    commands: c++
    linker: ld64
    name: clang
    version: 15.0.0
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 3.0.11
  fortran:
    commands: gfortran
    linker: ld64
    name: gcc
    version: 13.3.0
  pythran:
    include directory: ../../../../../../private/var/folders/g6/rgtlsw6n123b0gt5483s5_cm0000gn/T/pip-build-env-zkcj2f79/overlay/lib/python3.12/site-packages/pythran
    version: 0.17.0
Machine Information:
  build:
    cpu: aarch64
    endian: little
    family: aarch64
    system: darwin
  cross-compiled: false
  host:
    cpu: aarch64
    endian: little
    family: aarch64
    system: darwin
Python Information:
  path: /private/var/folders/g6/rgtlsw6n123b0gt5483s5_cm0000gn/T/cibw-run-7dkhp5o6/cp312-macosx_arm64/build/venv/bin/python
  version: '3.12'
```

Can reproduce.

```
import numpy as np
from scipy.integrate import trapezoid
import scipy
print(scipy.version.version)

x = np.linspace(0, 29, 30).reshape(3, 10)
y = np.linspace(0, 29, 30).reshape(3, 10)

print(trapezoid(y, x=x, axis=0))
print(trapezoid(y, x=x, axis=1))
```
@lucascolley @Armavica, it's not clear what [these lines do](https://github.com/scipy/scipy/blob/main/scipy/integrate/_quadrature.py#L139):

```
        # reshape to correct shape
        shape = [1] * y.ndim
        shape[axis] = y.shape[axis]
        x = xp.reshape(x, shape)
```

why is a reshape necessary?
the pre-existing code included a similar reshape, but of `d` and only when `x.ndim == 1`. I think the new reshape of `x` is serving the same purpose, as `d` is then constructed from the reshaped `x` rather than via `np.diff`. At a guess, @Armavica has accurately replicated `np.diff` for `x.ndim == 1`, but in higher dimensions, where the pre-existing code did not reshape, the new reshape is causing trouble.

`xp.diff` is coming in the next version of the standard, so at the worst we can just revert and wait for that to become available in array-api-compat. Maybe the fix here is easy, though.


diff --git a/scipy/signal/_signaltools.py b/scipy/signal/_signaltools.py
index f95b52c0c69f..3006bf014be5 100644
--- a/scipy/signal/_signaltools.py
+++ b/scipy/signal/_signaltools.py
@@ -405,6 +405,8 @@ def correlation_lags(in1_len, in2_len, mode='full'):
             lags = np.arange(lag_bound + 1)
         else:
             lags = np.arange(lag_bound, 1)
+    else:
+        raise ValueError(f"Mode {mode} is invalid")
     return lags

diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
index c5a5028e4624..4f8a573696b1 100644
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -2135,6 +2135,11 @@ def test_correlation_lags(mode, behind, input_size):
     assert_equal(lags.shape, correlation.shape)

+def test_correlation_lags_invalid_mode():
+    with pytest.raises(ValueError, match="Mode asdfgh is invalid"):
+        correlation_lags(100, 100, mode="asdfgh")
+
+
 @pytest.mark.parametrize('dt', [np.csingle, np.cdouble,
                                 pytest.param(np.clongdouble, marks=_pmf)])
 class TestCorrelateComplex:

BUG: signal: Confusing error when giving an invalid mode to `correlation_lags`
### Describe your issue.

When correlation_lags is given a mode that is not "valid", "same", or "full", the `lags` array is never defined, and the function fails with `UnboundLocalError: local variable 'lags' referenced before assignment`.
The fix is very simple, I will submit a PR soon.

### Reproducing Code Example

```python
signal.correlation_lags(10, 10, "some_invalid_format")
```

### Error message

```shell
Traceback (most recent call last)

<ipython-input-25-202bff163744> in <cell line: 1>()
----> 1 signal.correlation_lags(10, 10, "some_invalid_format")

/usr/local/lib/python3.10/dist-packages/scipy/signal/_signaltools.py in correlation_lags(in1_len, in2_len, mode)
    384         else:
    385             lags = np.arange(lag_bound, 1)
--> 386     return lags
    387
    388

UnboundLocalError: local variable 'lags' referenced before assignment
```

### SciPy/NumPy/Python version and system information

```shell
1.13.1 1.26.4 sys.version_info(major=3, minor=10, micro=12, releaselevel='final', serial=0)
Build Dependencies:
  blas:
    detection method: pkgconfig
    found: true
    include directory: /usr/local/include
    lib directory: /usr/local/lib
    name: openblas
    openblas configuration: USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= ZEN MAX_THREADS=64
    pc file directory: /usr/local/lib/pkgconfig
    version: 0.3.27
  lapack:
    detection method: pkgconfig
    found: true
    include directory: /usr/local/include
    lib directory: /usr/local/lib
    name: openblas
    openblas configuration: USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= ZEN MAX_THREADS=64
    pc file directory: /usr/local/lib/pkgconfig
    version: 0.3.27
  pybind11:
    detection method: config-tool
    include directory: unknown
    name: pybind11
    version: 2.12.0
Compilers:
  c:
    commands: cc
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  c++:
    commands: c++
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 3.0.10
  fortran:
    commands: gfortran
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  pythran:
    include directory: ../../tmp/pip-build-env-mnl4e8vy/overlay/lib/python3.10/site-packages/pythran
    version: 0.15.0
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: false
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /opt/python/cp310-cp310/bin/python
  version: '3.10'

```
```


diff --git a/scipy/special/_basic.py b/scipy/special/_basic.py
index 103f2c867632..5fe7f17e2784 100644
--- a/scipy/special/_basic.py
+++ b/scipy/special/_basic.py
@@ -11,7 +11,7 @@
                    sin, place, issubdtype, extract, inexact, nan, zeros, sinc)

 from . import _ufuncs
-from ._ufuncs import (mathieu_a, mathieu_b, iv, jv, gamma,
+from ._ufuncs import (mathieu_a, mathieu_b, iv, jv, gamma, rgamma,
                       psi, hankel1, hankel2, yv, kv, poch, binom,
                       _stirling2_inexact)

@@ -2911,34 +2911,68 @@ def _factorialx_array_exact(n, k=1):
     return out

-def _factorialx_array_approx(n, k):
+def _factorialx_array_approx(n, k, extend):
     """
     Calculate approximation to multifactorial for array n and integer k.

-    Ensure we only call _factorialx_approx_core where necessary/required.
+    Ensure that values aren't calculated unnecessarily.
     """
+    if extend == "complex":
+        return _factorialx_approx_core(n, k=k, extend=extend)
+
+    # at this point we are guaranteed that extend='zero' and that k>0 is an integer
     result = zeros(n.shape)
     # keep nans as nans
     place(result, np.isnan(n), np.nan)
     # only compute where n >= 0 (excludes nans), everything else is 0
     cond = (n >= 0)
     n_to_compute = extract(cond, n)
-    place(result, cond, _factorialx_approx_core(n_to_compute, k=k))
+    place(result, cond, _factorialx_approx_core(n_to_compute, k=k, extend=extend))
     return result

-def _factorialx_approx_core(n, k):
+def _gamma1p(vals):
+    """
+    returns gamma(n+1), though with NaN at -1 instead of inf, c.f. #21827
+    """
+    res = gamma(vals + 1)
+    # replace infinities at -1 (from gamma function at 0) with nan
+    # gamma only returns inf for real inputs; can ignore complex case
+    if isinstance(res, np.ndarray):
+        if not _is_subdtype(vals.dtype, "c"):
+            res[vals == -1] = np.nan
+    elif np.isinf(res) and vals == -1:
+        res = np.float64("nan")
+    return res
+
+
+def _factorialx_approx_core(n, k, extend):
     """
     Core approximation to multifactorial for array n and integer k.
     """
     if k == 1:
-        # shortcut for k=1
-        result = gamma(n + 1)
+        # shortcut for k=1; same for both extensions, because we assume the
+        # handling of extend == 'zero' happens in _factorialx_array_approx
+        result = _gamma1p(n)
         if isinstance(n, np.ndarray):
-            # gamma does not maintain 0-dim arrays
+            # gamma does not maintain 0-dim arrays; fix it
             result = np.array(result)
         return result

+    if extend == "complex":
+        # see https://numpy.org/doc/stable/reference/generated/numpy.power.html
+        p_dtype = complex if (_is_subdtype(type(k), "c") or k < 0) else None
+        with warnings.catch_warnings():
+            # do not warn about 0 * inf, nan / nan etc.; the results are correct
+            warnings.simplefilter("ignore", RuntimeWarning)
+            result = np.power(k, (n - 1) / k, dtype=p_dtype) * _gamma1p(n / k)
+            result *= rgamma(1 / k + 1)
+        if isinstance(n, np.ndarray):
+            # ensure we keep array-ness for 0-dim inputs; already n/k above loses it
+            result = np.array(result)
+        return result
+
+    # at this point we are guaranteed that extend='zero' and that k>0 is an integer
     n_mod_k = n % k
     # scalar case separately, unified handling would be inefficient for arrays;
     # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
@@ -2988,7 +3022,7 @@ def _is_subdtype(dtype, dtypes):
     return any(np.issubdtype(dtype, dt) for dt in dtypes)

-def factorial(n, exact=False):
+def factorial(n, exact=False, extend="zero"):
     """
     The factorial of a number or array of numbers.

@@ -3006,6 +3040,10 @@ def factorial(n, exact=False):
         If False, result is approximated in floating point rapidly using the
         `gamma` function.
         Default is False.
+    extend : string, optional
+        One of ``'zero'`` or ``'complex'``; this determines how values ``n<0``
+        are handled - by default they are 0, but it is possible to opt into the
+        complex extension of the factorial (the Gamma function).

     Returns
     -------
@@ -3036,47 +3074,64 @@ def factorial(n, exact=False):
     120

     """
+    if extend not in ("zero", "complex"):
+        raise ValueError(
+            f"argument `extend` must be either 'zero' or 'complex', received: {extend}"
+        )
+    if exact and extend == "complex":
+        raise ValueError("Incompatible options: `exact=True` and `extend='complex'`")
+
+    msg_needs_complex = (
+        "In order to use non-integer arguments, you must opt into this by passing "
+        "`extend='complex'`. Note that this changes the result for all negative "
+        "arguments (which by default return 0)."
+    )
     msg_wrong_dtype = (
         "Unsupported data type for factorial: {dtype}\n"
-        "Permitted data types are integers and floating point numbers."
+        "Permitted data types are integers and floating point numbers, "
+        "as well as complex numbers if `extend='complex'` is passed."
+    )
+    msg_exact_not_possible = (
+        "`exact=True` only supports integers, cannot use data type {dtype}"
     )

     # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
     if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
         # scalar cases
-        if not _is_subdtype(type(n), ["i", "f", type(None)]):
+        if not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
             raise ValueError(msg_wrong_dtype.format(dtype=type(n)))
+        elif _is_subdtype(type(n), "c") and extend != "complex":
+            raise ValueError(msg_needs_complex)
         elif n is None or np.isnan(n):
-            return np.float64("nan")
-        elif n < 0:
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            return np.complex128("nan+nanj") if complexify else np.float64("nan")
+        elif extend == "zero" and n < 0:
             return 0 if exact else np.float64(0)
         elif exact and _is_subdtype(type(n), "i"):
             return math.factorial(n)
         elif exact:
-            msg = ("Non-integer values of `n` together with `exact=True` are "
-                   "not supported. Either ensure integer `n` or use `exact=False`.")
-            raise ValueError(msg)
-        return _factorialx_approx_core(n, k=1)
+            raise ValueError(msg_exact_not_possible.format(dtype=type(n)))
+        return _factorialx_approx_core(n, k=1, extend=extend)

     # arrays & array-likes
     n = asarray(n)

-    if not _is_subdtype(n.dtype, ["i", "f"]):
+    if not _is_subdtype(n.dtype, ["i", "f", "c"]):
         raise ValueError(msg_wrong_dtype.format(dtype=n.dtype))
-    if exact and not _is_subdtype(n.dtype, "i"):
-        msg = ("factorial with `exact=True` does not "
-               "support non-integral arrays")
-        raise ValueError(msg)
+    elif _is_subdtype(n.dtype, "c") and extend != "complex":
+        raise ValueError(msg_needs_complex)
+    elif exact and _is_subdtype(n.dtype, ["f", "c"]):
+        raise ValueError(msg_exact_not_possible.format(dtype=n.dtype))

     if n.size == 0:
         # return empty arrays unchanged
         return n
     elif exact:
         return _factorialx_array_exact(n, k=1)
-    return _factorialx_array_approx(n, k=1)
+    return _factorialx_array_approx(n, k=1, extend=extend)

-def factorial2(n, exact=False):
+def factorial2(n, exact=False, extend="zero"):
     """Double factorial.

     This is the factorial with every second value skipped.  E.g., ``7!! = 7 * 5
@@ -3086,6 +3141,8 @@ def factorial2(n, exact=False):
           = 2 ** (n / 2) * gamma(n / 2 + 1)                 n even
           = 2 ** (n / 2) * (n / 2)!                         n even

+    The formula for `n odd` is the basis for the complex extension.
+
     Parameters
     ----------
     n : int or array_like
@@ -3094,6 +3151,17 @@ def factorial2(n, exact=False):
         The result can be approximated rapidly using the gamma-formula
         above (default).  If `exact` is set to True, calculate the
         answer exactly using integer arithmetic.
+    extend : string, optional
+        One of ``'zero'`` or ``'complex'``; this determines how values ``n<0``
+        are handled - by default they are 0, but it is possible to opt into the
+        complex extension of the double factorial. This also enables passing
+        complex values to ``n``.
+
+        .. warning::
+
+           Using the ``'complex'`` extension also changes the values of the
+           double factorial for even integers, reducing them by a factor of
+           ``sqrt(2/pi) ~= 0.79``, see [1].

     Returns
     -------
@@ -3109,41 +3177,68 @@ def factorial2(n, exact=False):
     >>> factorial2(7, exact=True)
     105

+    References
+    ----------
+    .. [1] Complex extension to double factorial
+            https://en.wikipedia.org/wiki/Double_factorial#Complex_arguments
     """
+    if extend not in ("zero", "complex"):
+        raise ValueError(
+            f"argument `extend` must be either 'zero' or 'complex', received: {extend}"
+        )
+    if exact and extend == "complex":
+        raise ValueError("Incompatible options: `exact=True` and `extend='complex'`")
+
+    msg_needs_complex = (
+        "In order to use non-integer arguments, you must opt into this by passing "
+        "`extend='complex'`. Note that this changes the result for all negative "
+        "arguments (which by default return 0). Additionally, it will rescale the "
+        "values of the double factorial at even integers by a factor of sqrt(2/pi)."
+    )
     msg_wrong_dtype = (
         "Unsupported data type for factorial2: {dtype}\n"
-        "Only integers are permitted."
+        "Only integers are permitted by default, though floating point "
+        "and complex numbers can be used if `extend='complex'` is passed."
     )

     # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
     if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
         # scalar cases
-        if not _is_subdtype(type(n), "i"):
+        if not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
             raise ValueError(msg_wrong_dtype.format(dtype=type(n)))
-        elif n < 0:
-            return 0
+        elif _is_subdtype(type(n), ["f", "c"]) and extend != "complex":
+            raise ValueError(msg_needs_complex)
+        elif n is None or np.isnan(n):
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            return np.complex128("nan+nanj") if complexify else np.float64("nan")
+        elif extend == "zero" and n < 0:
+            return 0 if exact else np.float64(0)
         elif n in {0, 1}:
-            return 1
-        # general integer case
+            return 1 if exact else np.float64(1)
+
         if exact:
+            # general integer case
             return _range_prod(1, n, k=2)
-        return _factorialx_approx_core(n, k=2)
+        # approximation
+        return _factorialx_approx_core(n, k=2, extend=extend)

     # arrays & array-likes
     n = asarray(n)

-    if not _is_subdtype(n.dtype, "i"):
+    if not _is_subdtype(n.dtype, ["i", "f", "c"]):
         raise ValueError(msg_wrong_dtype.format(dtype=n.dtype))
+    elif _is_subdtype(n.dtype, ["f", "c"]) and extend != "complex":
+        raise ValueError(msg_needs_complex)

     if n.size == 0:
         # return empty arrays unchanged
         return n
     elif exact:
         return _factorialx_array_exact(n, k=2)
-    return _factorialx_array_approx(n, k=2)
+    return _factorialx_array_approx(n, k=2, extend=extend)

-def factorialk(n, k, exact=None):
+def factorialk(n, k, exact=None, extend="zero"):
     """Multifactorial of n of order k, n(!!...!).

     This is the multifactorial of n skipping k values.  For example,
@@ -3170,6 +3265,17 @@ def factorialk(n, k, exact=None):
         .. warning::
            The default value for ``exact`` will be changed to
            ``False`` in SciPy 1.15.0.
+    extend : string, optional
+        One of ``'zero'`` or ``'complex'``; this determines how values `n<0` are
+        handled - by default they are 0, but it is possible to opt into the complex
+        extension of the multifactorial. This enables passing complex values,
+        not only to ``n`` but also to ``k``.
+
+        .. warning::
+
+           Using the ``'complex'`` extension also changes the values of the
+           multifactorial at integers ``n != 1 (mod k)`` by a factor depending
+           on both ``k`` and ``n % k``, see below or [1].

     Returns
     -------
@@ -3197,15 +3303,23 @@ def factorialk(n, k, exact=None):

       n!(k) = k ** ((n - r)/k) * gamma(n/k + 1) / gamma(r/k + 1) * max(r, 1)

-    This is the basis of the approximation when ``exact=False``. Compare also [1].
+    This is the basis of the approximation when ``exact=False``.
+
+    In principle, any fixed choice of ``r`` (ignoring its relation ``r = n%k``
+    to ``n``) would provide a suitable analytic continuation from integer ``n``
+    to complex ``z`` (not only satisfying the functional equation but also
+    being logarithmically convex, c.f. Bohr-Mollerup theorem) -- in fact, the
+    choice of ``r`` above only changes the function by a constant factor. The
+    final constraint that determines the canonical continuation is ``f(1) = 1``,
+    which forces ``r = 1`` (see also [1]).::
+
+      z!(k) = k ** ((z - 1)/k) * gamma(z/k + 1) / gamma(1/k + 1)

     References
     ----------
     .. [1] Complex extension to multifactorial
             https://en.wikipedia.org/wiki/Double_factorial#Alternative_extension_of_the_multifactorial
     """
-    if not _is_subdtype(type(k), "i") or k < 1:
-        raise ValueError(f"k must be a positive integer, received: {k}")
     if exact is None:
         msg = (
             "factorialk will default to `exact=False` starting from SciPy "
@@ -3216,42 +3330,72 @@ def factorialk(n, k, exact=None):
         warnings.warn(msg, DeprecationWarning, stacklevel=2)
         exact = True

+    if extend not in ("zero", "complex"):
+        raise ValueError(
+            f"argument `extend` must be either 'zero' or 'complex', received: {extend}"
+        )
+    if exact and extend == "complex":
+        raise ValueError("Incompatible options: `exact=True` and `extend='complex'`")
+
+    msg_needs_complex = (
+        "In order to use non-integer arguments, you must opt into this by passing "
+        "`extend='complex'`. Note that this changes the result for all negative "
+        "arguments (which by default return 0). Additionally, it will perturb "
+        "the values of the multifactorial at most positive integers `n`."
+    )
     msg_wrong_dtype = (
-        "Unsupported data type for factorialk: {dtype}\n"
-        "Only integers are permitted."
+        "Unsupported data type for factorialk in {varname}: {dtype}\n"
+        "Only integers are permitted by default, though floating point "
+        "and complex numbers can be used if `extend='complex'` is passed."
     )

-    helpmsg = ""
-    if k in {1, 2}:
-        func = "factorial" if k == 1 else "factorial2"
-        helpmsg = f"\nYou can try to use {func} instead"
+    # check type of k
+    if not _is_subdtype(type(k), ["i", "f", "c"]):
+        raise ValueError(msg_wrong_dtype.format(varname="k", dtype=type(k)))
+    elif _is_subdtype(type(k), ["f", "c"]) and extend != "complex":
+        raise ValueError(msg_needs_complex)
+    # check value of k
+    if extend == "zero" and k < 1:
+        msg = f"For `extend='zero'`, k must be a positive integer, received: {k}"
+        raise ValueError(msg)
+    elif k == 0:
+        raise ValueError("Parameter k cannot be zero!")

     # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
     if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
         # scalar cases
-        if not _is_subdtype(type(n), "i"):
-            raise ValueError(msg_wrong_dtype.format(dtype=type(n)) + helpmsg)
-        elif n < 0:
-            return 0
+        if not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
+            raise ValueError(msg_wrong_dtype.format(varname="n", dtype=type(n)))
+        elif _is_subdtype(type(n), ["f", "c"]) and extend != "complex":
+            raise ValueError(msg_needs_complex)
+        elif n is None or np.isnan(n):
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            return np.complex128("nan+nanj") if complexify else np.float64("nan")
+        elif extend == "zero" and n < 0:
+            return 0 if exact else np.float64(0)
         elif n in {0, 1}:
-            return 1
-        # general integer case
+            return 1 if exact else np.float64(1)
+
         if exact:
+            # general integer case
             return _range_prod(1, n, k=k)
-        return _factorialx_approx_core(n, k=k)
+        # approximation
+        return _factorialx_approx_core(n, k=k, extend=extend)

     # arrays & array-likes
     n = asarray(n)

-    if not _is_subdtype(n.dtype, "i"):
-        raise ValueError(msg_wrong_dtype.format(dtype=n.dtype) + helpmsg)
+    if not _is_subdtype(n.dtype, ["i", "f", "c"]):
+        raise ValueError(msg_wrong_dtype.format(varname="n", dtype=n.dtype))
+    elif _is_subdtype(n.dtype, ["f", "c"]) and extend != "complex":
+        raise ValueError(msg_needs_complex)

     if n.size == 0:
         # return empty arrays unchanged
         return n
     elif exact:
         return _factorialx_array_exact(n, k=k)
-    return _factorialx_array_approx(n, k=k)
+    return _factorialx_array_approx(n, k=k, extend=extend)

 def stirling2(N, K, *, exact=False):

diff --git a/scipy/_lib/tests/test_warnings.py b/scipy/_lib/tests/test_warnings.py
index f18bc20cb033..f200b1a6e975 100644
--- a/scipy/_lib/tests/test_warnings.py
+++ b/scipy/_lib/tests/test_warnings.py
@@ -115,6 +115,7 @@ def test_warning_calls_filters(warning_calls):
         os.path.join('optimize', '_nnls.py'),
         os.path.join('signal', '_ltisys.py'),
         os.path.join('sparse', '__init__.py'),  # np.matrix pending-deprecation
+        os.path.join('special', '_basic.py'),  # gh-21801
         os.path.join('stats', '_discrete_distns.py'),  # gh-14901
         os.path.join('stats', '_continuous_distns.py'),
         os.path.join('stats', '_binned_statistic.py'),  # gh-19345
diff --git a/scipy/special/tests/test_basic.py b/scipy/special/tests/test_basic.py
index 259a6fd35055..54d2bb0570f5 100644
--- a/scipy/special/tests/test_basic.py
+++ b/scipy/special/tests/test_basic.py
@@ -2133,28 +2133,39 @@ def assert_complex_nan(x):
             assert_really_equal(elem_x, elem_y, rtol=rtol)
     elif np.isnan(x) and np.isnan(y) and _is_subdtype(type(x), "c"):
         assert_complex_nan(x) and assert_complex_nan(y)
+    # no need to consider complex infinities due to numpy/numpy#25493
     else:
         assert_func(x, y)

 class TestFactorialFunctions:
-    @pytest.mark.parametrize("exact", [True, False])
-    def test_factorialx_scalar_return_type(self, exact):
-        kw = {"exact": exact}
+    def factorialk_ref(self, n, k, exact, extend):
+        if exact:
+            return special.factorialk(n, k=k, exact=True)
+        # for details / explanation see factorialk-docstring
+        r = np.mod(n, k) if extend == "zero" else 1
+        vals = np.power(k, (n - r)/k) * special.gamma(n/k + 1) * special.rgamma(r/k + 1)
+        # np.maximum is element-wise, which is what we want
+        return vals * np.maximum(r, 1)
+
+    @pytest.mark.parametrize("exact,extend",
+                             [(True, "zero"), (False, "zero"), (False, "complex")])
+    def test_factorialx_scalar_return_type(self, exact, extend):
+        kw = {"exact": exact, "extend": extend}
         assert np.isscalar(special.factorial(1, **kw))
         assert np.isscalar(special.factorial2(1, **kw))
         assert np.isscalar(special.factorialk(1, k=3, **kw))

     @pytest.mark.parametrize("n", [-1, -2, -3])
     @pytest.mark.parametrize("exact", [True, False])
-    def test_factorialx_negative(self, exact, n):
+    def test_factorialx_negative_extend_zero(self, exact, n):
         kw = {"exact": exact}
         assert_equal(special.factorial(n, **kw), 0)
         assert_equal(special.factorial2(n, **kw), 0)
         assert_equal(special.factorialk(n, k=3, **kw), 0)

     @pytest.mark.parametrize("exact", [True, False])
-    def test_factorialx_negative_array(self, exact):
+    def test_factorialx_negative_extend_zero_array(self, exact):
         kw = {"exact": exact}
         rtol = 1e-15
         n = [-5, -4, 0, 1]
@@ -2164,42 +2175,115 @@ def test_factorialx_negative_array(self, exact):
         assert_really_equal(special.factorial2(n, **kw), expected, rtol=rtol)
         assert_really_equal(special.factorialk(n, k=3, **kw), expected, rtol=rtol)

+    @pytest.mark.parametrize("n", [-1.1, -2.2, -3.3])
+    def test_factorialx_negative_extend_complex(self, n):
+        # factorialk defaults to exact=True, need to explicitly set to False
+        kw = {"exact": False, "extend": "complex"}
+        exp_1 = {-1.1: -10.686287021193184771,
+                 -2.2:   4.8509571405220931958,
+                 -3.3:  -1.4471073942559181166}
+        exp_2 = {-1.1:  1.0725776858167496309,
+                 -2.2: -3.9777171783768419874,
+                 -3.3: -0.99588841846200555977}
+        exp_k = {-1.1:  0.73565345382163025659,
+                 -2.2:  1.1749163167190809498,
+                 -3.3: -2.4780584257450583713}
+        rtol = 3e-15
+        assert_allclose(special.factorial(n, **kw), exp_1[n], rtol=rtol)
+        assert_allclose(special.factorial2(n, **kw), exp_2[n], rtol=rtol)
+        assert_allclose(special.factorialk(n, k=3, **kw), exp_k[n], rtol=rtol)
+        assert_allclose(special.factorial([n], **kw)[0], exp_1[n], rtol=rtol)
+        assert_allclose(special.factorial2([n], **kw)[0], exp_2[n], rtol=rtol)
+        assert_allclose(special.factorialk([n], k=3, **kw)[0], exp_k[n], rtol=rtol)
+
+    @pytest.mark.parametrize("imag", [0, 0j])
+    @pytest.mark.parametrize("n_outer", [-1, -2, -3])
+    def test_factorialx_negative_extend_complex_poles(self, n_outer, imag):
+        def _check(n):
+            complexify = _is_subdtype(type(n), "c")
+            # like for gamma, we expect complex nans for complex inputs
+            complex_nan = np.complex128("nan+nanj")
+            exp = np.complex128("nan+nanj") if complexify else np.float64("nan")
+            # factorialk defaults to incompatible exact=True, explicitly set to False
+            kw = {"exact": False, "extend": "complex"}
+            # poles are at negative integers that are multiples of k
+            assert_really_equal(special.factorial(n, **kw), exp)
+            assert_really_equal(special.factorial2(n * 2, **kw), exp)
+            assert_really_equal(special.factorialk(n * 3, k=3, **kw), exp)
+            # also test complex k for factorialk
+            c = 1.5 - 2j
+            assert_really_equal(special.factorialk(n * c, k=c, **kw), complex_nan)
+            # same for array case
+            assert_really_equal(special.factorial([n], **kw)[0], exp)
+            assert_really_equal(special.factorial2([n * 2], **kw)[0], exp)
+            assert_really_equal(special.factorialk([n * 3], k=3, **kw)[0], exp)
+            assert_really_equal(special.factorialk([n * c], k=c, **kw)[0], complex_nan)
+            # more specific tests in test_factorial{,2,k}_complex_reference
+
+        # imag ensures we test both real and complex representations of the poles
+        _check(n_outer + imag)
+        # check for large multiple of period
+        _check(100_000 * n_outer + imag)
+
     @pytest.mark.parametrize("boxed", [True, False])
+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize(
         "n",
         [
             np.nan, np.float64("nan"), np.nan + np.nan*1j, np.complex128("nan+nanj"),
-            None, np.datetime64("nat")
+            np.inf, np.inf + 0j, -np.inf, -np.inf + 0j, None, np.datetime64("nat")
         ],
         ids=[
             "NaN", "np.float64('nan')", "NaN+i*NaN", "np.complex128('nan+nanj')",
-            "None", "NaT"
+            "inf", "inf+0i", "-inf", "-inf+0i", "None", "NaT"
         ]
     )
     @pytest.mark.parametrize(
         "factorialx",
         [special.factorial, special.factorial2, special.factorialk]
     )
-    def test_factorialx_nan(self, factorialx, n, boxed):
+    def test_factorialx_inf_nan(self, factorialx, n, extend, boxed):
         # NaNs not allowed (by dtype) for exact=True
-        kw = {"exact": False}
+        kw = {"exact": False, "extend": extend}
         if factorialx == special.factorialk:
             kw["k"] = 3

-        permissible_types = ["i"]
-        # factorial also allows floats
-        if factorialx == special.factorial:
-            # None is allowed for scalars, but would cause object type in array case
-            permissible_types = ["i", "f"] if boxed else ["i", "f", type(None)]
+        # None is allowed for scalars, but would cause object type in array case
+        permissible_types = ["i", "f", "c"] if boxed else ["i", "f", "c", type(None)]
+        # factorial allows floats also for extend="zero"
+        types_need_complex_ext = "c" if factorialx == special.factorial else ["f", "c"]

         if not _is_subdtype(type(n), permissible_types):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 factorialx([n] if boxed else n, **kw)
+        elif _is_subdtype(type(n), types_need_complex_ext) and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                factorialx([n] if boxed else n, **kw)
         else:
+            # account for type and whether extend="complex"
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            # note that the type of the naïve `np.nan + np.nan * 1j` is `complex`
+            # instead of `numpy.complex128`, which trips up assert_really_equal
+            expected = np.complex128("nan+nanj") if complexify else np.float64("nan")
+            # the only exception are real infinities
+            if _is_subdtype(type(n), "f") and np.isinf(n):
+                # unchanged for positive infinity; negative one depends on extension
+                neg_inf_result = np.float64(0 if (extend == "zero") else "nan")
+                expected = np.float64("inf") if (n > 0) else neg_inf_result
+
             result = factorialx([n], **kw)[0] if boxed else factorialx(n, **kw)
-            assert_really_equal(result, np.float64("nan"))
+            assert_really_equal(result, expected)
             # also tested in test_factorial{,2,k}_{array,scalar}_corner_cases

+    @pytest.mark.parametrize("extend", [0, 1.1, np.nan, "string"])
+    def test_factorialx_raises_extend(self, extend):
+        with pytest.raises(ValueError, match="argument `extend` must be.*"):
+            special.factorial(1, extend=extend)
+        with pytest.raises(ValueError, match="argument `extend` must be.*"):
+            special.factorial2(1, extend=extend)
+        with pytest.raises(ValueError, match="argument `extend` must be.*"):
+            special.factorialk(1, k=3, exact=True, extend=extend)
+
     @pytest.mark.parametrize("levels", range(1, 5))
     @pytest.mark.parametrize("exact", [True, False])
     def test_factorialx_array_shape(self, levels, exact):
@@ -2294,15 +2378,20 @@ def test_factorial_int_reference(self, n):
         assert_allclose(correct, special.factorial(n, exact=False), rtol=rtol)
         assert_allclose(correct, special.factorial([n], exact=False)[0], rtol=rtol)

+        # extend="complex" only works for exact=False
+        kw = {"exact": False, "extend": "complex"}
+        assert_allclose(correct, special.factorial(n, **kw), rtol=rtol)
+        assert_allclose(correct, special.factorial([n], **kw)[0], rtol=rtol)
+
     def test_factorial_float_reference(self):
         def _check(n, expected):
             rtol = 8e-14 if sys.platform == 'win32' else 1e-15
             assert_allclose(special.factorial(n), expected, rtol=rtol)
             assert_allclose(special.factorial([n])[0], expected, rtol=rtol)
             # using floats with `exact=True` raises an error for scalars and arrays
-            with pytest.raises(ValueError, match="Non-integer values.*"):
-                assert_allclose(special.factorial(n, exact=True), expected, rtol=rtol)
-            with pytest.raises(ValueError, match="factorial with `exact=Tr.*"):
+            with pytest.raises(ValueError, match="`exact=True` only supports.*"):
+                special.factorial(n, exact=True)
+            with pytest.raises(ValueError, match="`exact=True` only supports.*"):
                 special.factorial([n], exact=True)

         # Reference values from mpmath for gamma(n+1)
@@ -2317,8 +2406,25 @@ def _check(n, expected):
         # close to maximum for float64
         _check(170.6243, 1.79698185749571048960082e+308)

+    def test_factorial_complex_reference(self):
+        def _check(n, expected):
+            rtol = 3e-15 if sys.platform == 'win32' else 2e-15
+            kw = {"exact": False, "extend": "complex"}
+            assert_allclose(special.factorial(n, **kw), expected, rtol=rtol)
+            assert_allclose(special.factorial([n], **kw)[0], expected, rtol=rtol)
+
+        # Reference values from mpmath.gamma(n+1)
+        # negative & complex values
+        _check(-0.5, expected=1.7724538509055160276)
+        _check(-0.5 + 0j, expected=1.7724538509055160276 + 0j)
+        _check(2 + 2j, expected=-0.42263728631120216694 + 0.87181425569650686062j)
+        # close to poles
+        _check(-0.9999, expected=9999.422883232725532)
+        _check(-1 + 0.0001j, expected=-0.57721565582674219 - 9999.9999010944009697j)
+
     @pytest.mark.parametrize("dtype", [np.int64, np.float64,
                                        np.complex128, object])
+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("dim", range(0, 5))
     # test empty & non-empty arrays, with nans and mixed
@@ -2327,7 +2433,7 @@ def _check(n, expected):
         [[], [1], [1.1], [np.nan], [np.nan + np.nan * 1j], [np.nan, 1]],
         ids=["[]", "[1]", "[1.1]", "[NaN]", "[NaN+i*NaN]", "[NaN, 1]"],
     )
-    def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
+    def test_factorial_array_corner_cases(self, content, dim, exact, extend, dtype):
         if dtype is object and SCIPY_ARRAY_API:
             pytest.skip("object arrays unsupported in array API mode")
         # get dtype without calling array constructor (that might fail or mutate)
@@ -2336,17 +2442,23 @@ def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
         if dtype == np.float64 and any(_is_subdtype(type(x), "c") for x in content):
             pytest.skip("impossible combination")

-        kw = {"exact": exact}
+        kw = {"exact": exact, "extend": extend}
         # np.array(x, ndim=0) will not be 0-dim. unless x is too
         content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)

         result = None
-        if not _is_subdtype(n.dtype, ["i", "f"]):
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorial(n, **kw)
+        elif not _is_subdtype(n.dtype, ["i", "f", "c"]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorial(n, **kw)
+        elif _is_subdtype(n.dtype, "c") and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorial(n, **kw)
         elif exact and not _is_subdtype(n.dtype, "i"):
-            with pytest.raises(ValueError, match="factorial with `exact=.*"):
+            with pytest.raises(ValueError, match="`exact=True` only supports.*"):
                 special.factorial(n, **kw)
         else:
             result = special.factorial(n, **kw)
@@ -2359,22 +2471,33 @@ def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
             # result is empty if and only if n is empty, and has the same dimension
             # as n; dtype stays the same, except when not empty and not exact:
             if n.size:
-                dtype = native_int if exact else np.float64
+                cx = (extend == "complex") and _is_subdtype(n.dtype, "c")
+                dtype = np.complex128 if cx else (native_int if exact else np.float64)
             expected = np.array(ref, ndmin=dim, dtype=dtype)
-            assert_really_equal(result, expected)
+            assert_really_equal(result, expected, rtol=1e-15)

+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, np.nan + np.nan*1j, None],
                              ids=["1", "1.1", "2+2j", "NaN", "NaN+i*NaN", "None"])
-    def test_factorial_scalar_corner_cases(self, n, exact):
-        kw = {"exact": exact}
-        if not _is_subdtype(type(n), ["i", "f", type(None)]):
+    def test_factorial_scalar_corner_cases(self, n, exact, extend):
+        kw = {"exact": exact, "extend": extend}
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorial(n, **kw)
+        elif not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorial(n, **kw)
+        elif _is_subdtype(type(n), "c") and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorial(n, **kw)
         elif n is None or np.isnan(n):
-            assert_really_equal(special.factorial(n, **kw), np.float64("nan"))
+            # account for dtype and whether extend="complex"
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            expected = np.complex128("nan+nanj") if complexify else np.float64("nan")
+            assert_really_equal(special.factorial(n, **kw), expected)
         elif exact and _is_subdtype(type(n), "f"):
-            with pytest.raises(ValueError, match="Non-integer values.*"):
+            with pytest.raises(ValueError, match="`exact=True` only supports.*"):
                 special.factorial(n, **kw)
         else:
             assert_equal(special.factorial(n, **kw), special.gamma(n + 1))
@@ -2409,8 +2532,37 @@ def test_factorial2_int_reference(self, n):
         assert_allclose(correct, special.factorial2(n, exact=False), rtol=rtol)
         assert_allclose(correct, special.factorial2([n], exact=False)[0], rtol=rtol)

+        # extend="complex" only works for exact=False
+        kw = {"exact": False, "extend": "complex"}
+        # approximation only matches exactly for `n == 1 (mod k)`, see docstring
+        if n % 2 == 1:
+            assert_allclose(correct, special.factorial2(n, **kw), rtol=rtol)
+            assert_allclose(correct, special.factorial2([n], **kw)[0], rtol=rtol)
+
+    def test_factorial2_complex_reference(self):
+        # this tests for both floats and complex
+        def _check(n, expected):
+            rtol = 5e-15
+            kw = {"exact": False, "extend": "complex"}
+            assert_allclose(special.factorial2(n, **kw), expected, rtol=rtol)
+            assert_allclose(special.factorial2([n], **kw)[0], expected, rtol=rtol)
+
+        # Reference values from mpmath for:
+        # mpmath.power(2, n/2) * mpmath.gamma(n/2 + 1) * mpmath.sqrt(2 / mpmath.pi)
+        _check(3, expected=3)
+        _check(4, expected=special.factorial2(4) * math.sqrt(2 / math.pi))
+        _check(20, expected=special.factorial2(20) * math.sqrt(2 / math.pi))
+        # negative & complex values
+        _check(-0.5, expected=0.82217895866245855122)
+        _check(-0.5 + 0j, expected=0.82217895866245855122 + 0j)
+        _check(3 + 3j, expected=-1.0742236630142471526 + 1.4421398439387262897j)
+        # close to poles
+        _check(-1.9999, expected=7978.8918745523440682)
+        _check(-2 + 0.0001j, expected=0.0462499835314308444 - 7978.84559148876374493j)
+
     @pytest.mark.parametrize("dtype", [np.int64, np.float64,
                                        np.complex128, object])
+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("dim", range(0, 5))
     # test empty & non-empty arrays, with nans and mixed
@@ -2419,22 +2571,28 @@ def test_factorial2_int_reference(self, n):
         [[], [1], [1.1], [np.nan], [np.nan + np.nan * 1j], [np.nan, 1]],
         ids=["[]", "[1]", "[1.1]", "[NaN]", "[NaN+i*NaN]", "[NaN, 1]"],
     )
-    def test_factorial2_array_corner_cases(self, content, dim, exact, dtype):
+    def test_factorial2_array_corner_cases(self, content, dim, exact, extend, dtype):
         # get dtype without calling array constructor (that might fail or mutate)
         if dtype == np.int64 and any(np.isnan(x) or (x != int(x)) for x in content):
             pytest.skip("impossible combination")
         if dtype == np.float64 and any(_is_subdtype(type(x), "c") for x in content):
             pytest.skip("impossible combination")

-        kw = {"exact": exact}
+        kw = {"exact": exact, "extend": extend}
         # np.array(x, ndim=0) will not be 0-dim. unless x is too
         content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)

         result = None
-        if not _is_subdtype(n.dtype, "i"):
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorial2(n, **kw)
+        elif not _is_subdtype(n.dtype, ["i", "f", "c"]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorial2(n, **kw)
+        elif _is_subdtype(n.dtype, ["f", "c"]) and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorial2(n, **kw)
         else:
             result = special.factorial2(n, **kw)

@@ -2446,20 +2604,34 @@ def test_factorial2_array_corner_cases(self, content, dim, exact, dtype):
             # result is empty if and only if n is empty, and has the same dimension
             # as n; dtype stays the same, except when not empty and not exact:
             if n.size:
-                dtype = native_int if exact else np.float64
+                cx = (extend == "complex") and _is_subdtype(n.dtype, "c")
+                dtype = np.complex128 if cx else (native_int if exact else np.float64)
             expected = np.array(ref, ndmin=dim, dtype=dtype)
-            assert_really_equal(result, expected, rtol=1e-15)
+            assert_really_equal(result, expected, rtol=2e-15)

+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, np.nan + np.nan*1j, None],
                              ids=["1", "1.1", "2+2j", "NaN", "NaN+i*NaN", "None"])
-    def test_factorial2_scalar_corner_cases(self, n, exact):
-        kw = {"exact": exact}
-        if not _is_subdtype(type(n), "i"):
+    def test_factorial2_scalar_corner_cases(self, n, exact, extend):
+        kw = {"exact": exact, "extend": extend}
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorial2(n, **kw)
+        elif not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorial2(n, **kw)
+        elif _is_subdtype(type(n), ["f", "c"]) and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorial2(n, **kw)
+        elif n is None or np.isnan(n):
+            # account for dtype and whether extend="complex"
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            expected = np.complex128("nan+nanj") if complexify else np.float64("nan")
+            assert_really_equal(special.factorial2(n, **kw), expected)
         else:
-            assert_equal(special.factorial2(n, **kw), 1)
+            expected = self.factorialk_ref(n, k=2, **kw)
+            assert_really_equal(special.factorial2(n, **kw), expected, rtol=1e-15)

     @pytest.mark.parametrize("k", range(1, 5))
     # note that n=170 is the last integer such that factorial(n) fits float64;
@@ -2495,8 +2667,47 @@ def test_factorialk_int_reference(self, n, k):
         assert_allclose(correct, special.factorialk(n, k, exact=False), rtol=rtol)
         assert_allclose(correct, special.factorialk([n], k, exact=False)[0], rtol=rtol)

+        # extend="complex" only works for exact=False
+        kw = {"k": k, "exact": False, "extend": "complex"}
+        # approximation only matches exactly for `n == 1 (mod k)`, see docstring
+        if n % k == 1:
+            rtol = 2e-14
+            assert_allclose(correct, special.factorialk(n, **kw), rtol=rtol)
+            assert_allclose(correct, special.factorialk([n], **kw)[0], rtol=rtol)
+
+    def test_factorialk_complex_reference(self):
+        # this tests for both floats and complex
+        def _check(n, k, exp):
+            rtol = 1e-14
+            kw = {"k": k, "exact": False, "extend": "complex"}
+            assert_allclose(special.factorialk(n, **kw), exp, rtol=rtol)
+            assert_allclose(special.factorialk([n], **kw)[0], exp, rtol=rtol)
+
+        # Reference values from mpmath for:
+        # mpmath.power(k, (n-1)/k) * mpmath.gamma(n/k + 1) / mpmath.gamma(1/k + 1)
+        _check(n=4, k=3, exp=special.factorialk(4, k=3, exact=True))
+        _check(n=5, k=3, exp=7.29011132947227083)
+        _check(n=6.5, k=3, exp=19.6805080113566010)
+        # non-integer k
+        _check(n=3, k=2.5, exp=2.58465740293218541)
+        _check(n=11, k=2.5, exp=1963.5)  # ==11*8.5*6*3.5; c.f. n == 1 (mod k)
+        _check(n=-3 + 3j + 1, k=-3 + 3j, exp=-2 + 3j)
+        # complex values
+        _check(n=4 + 4j, k=4, exp=-0.67855904082768043854 + 2.1993925819930311497j)
+        _check(n=4, k=4 - 4j, exp=1.9775338957222718742 + 0.92607172675423901371j)
+        _check(n=4 + 4j, k=4 - 4j, exp=0.1868492880824934475 + 0.87660580316894290247j)
+        # negative values
+        _check(n=-0.5, k=3, exp=0.72981013240713739354)
+        _check(n=-0.5 + 0j, k=3, exp=0.72981013240713739354 + 0j)
+        _check(n=2.9, k=-0.7, exp=0.45396591474966867296 + 0.56925525174685228866j)
+        _check(n=-0.6, k=-0.7, exp=-0.07190820089634757334 - 0.090170031876701730081j)
+        # close to poles
+        _check(n=-2.9999, k=3, exp=7764.7170695908828364)
+        _check(n=-3 + 0.0001j, k=3, exp=0.1349475632879599864 - 7764.5821055158365027j)
+
     @pytest.mark.parametrize("dtype", [np.int64, np.float64,
                                        np.complex128, object])
+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("dim", range(0, 5))
     # test empty & non-empty arrays, with nans and mixed
@@ -2505,22 +2716,28 @@ def test_factorialk_int_reference(self, n, k):
         [[], [1], [1.1], [np.nan], [np.nan + np.nan * 1j], [np.nan, 1]],
         ids=["[]", "[1]", "[1.1]", "[NaN]", "[NaN+i*NaN]", "[NaN, 1]"],
     )
-    def test_factorialk_array_corner_cases(self, content, dim, exact, dtype):
+    def test_factorialk_array_corner_cases(self, content, dim, exact, extend, dtype):
         # get dtype without calling array constructor (that might fail or mutate)
         if dtype == np.int64 and any(np.isnan(x) or (x != int(x)) for x in content):
             pytest.skip("impossible combination")
         if dtype == np.float64 and any(_is_subdtype(type(x), "c") for x in content):
             pytest.skip("impossible combination")

-        kw = {"k": 3, "exact": exact}
+        kw = {"k": 3, "exact": exact, "extend": extend}
         # np.array(x, ndim=0) will not be 0-dim. unless x is too
         content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)

         result = None
-        if not _is_subdtype(n.dtype, "i"):
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorialk(n, **kw)
+        elif not _is_subdtype(n.dtype, ["i", "f", "c"]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorialk(n, **kw)
+        elif _is_subdtype(n.dtype, ["f", "c"]) and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorialk(n, **kw)
         else:
             result = special.factorialk(n, **kw)

@@ -2532,22 +2749,35 @@ def test_factorialk_array_corner_cases(self, content, dim, exact, dtype):
             # result is empty if and only if n is empty, and has the same dimension
             # as n; dtype stays the same, except when not empty and not exact:
             if n.size:
-                dtype = native_int if exact else np.float64
+                cx = (extend == "complex") and _is_subdtype(n.dtype, "c")
+                dtype = np.complex128 if cx else (native_int if exact else np.float64)
             expected = np.array(ref, ndmin=dim, dtype=dtype)
-            assert_really_equal(result, expected, rtol=1e-15)
+            assert_really_equal(result, expected, rtol=2e-15)

+    @pytest.mark.parametrize("extend", ["zero", "complex"])
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("k", range(1, 5))
     @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, np.nan + np.nan*1j, None],
                              ids=["1", "1.1", "2+2j", "NaN", "NaN+i*NaN", "None"])
-    def test_factorialk_scalar_corner_cases(self, n, k, exact):
-        kw = {"k": k, "exact": exact}
-        if not _is_subdtype(type(n), "i"):
+    def test_factorialk_scalar_corner_cases(self, n, k, exact, extend):
+        kw = {"k": k, "exact": exact, "extend": extend}
+        if extend == "complex" and exact:
+            with pytest.raises(ValueError, match="Incompatible options:.*"):
+                special.factorialk(n, **kw)
+        elif not _is_subdtype(type(n), ["i", "f", "c", type(None)]):
             with pytest.raises(ValueError, match="Unsupported data type.*"):
                 special.factorialk(n, **kw)
+        elif _is_subdtype(type(n), ["f", "c"]) and extend != "complex":
+            with pytest.raises(ValueError, match="In order to use non-integer.*"):
+                special.factorialk(n, **kw)
+        elif n is None or np.isnan(n):
+            # account for dtype and whether extend="complex"
+            complexify = (extend == "complex") and _is_subdtype(type(n), "c")
+            expected = np.complex128("nan+nanj") if complexify else np.float64("nan")
+            assert_really_equal(special.factorialk(n, **kw), expected)
         else:
-            # factorialk(1, k) == 1 for all k
-            assert_equal(special.factorialk(n, **kw), 1)
+            expected = self.factorialk_ref(n, **kw)
+            assert_really_equal(special.factorialk(n, **kw), expected, rtol=1e-15)

     @pytest.mark.parametrize("k", range(1, 5))
     def test_factorialk_deprecation_exact(self, k):
@@ -2557,29 +2787,42 @@ def test_factorialk_deprecation_exact(self, k):
             special.factorialk(1, k=k)

     @pytest.mark.parametrize("boxed", [True, False])
-    @pytest.mark.parametrize("exact", [True, False])
-    @pytest.mark.parametrize("k", [0, 1.1, np.nan])
-    def test_factorialk_raises_k_complex(self, k, exact, boxed):
+    @pytest.mark.parametrize("exact,extend",
+                             [(True, "zero"), (False, "zero"), (False, "complex")])
+    @pytest.mark.parametrize("k", [-1, -1.0, 0, 0.0, 0 + 1j, 1.1, np.nan])
+    def test_factorialk_raises_k_complex(self, k, exact, extend, boxed):
         n = [1] if boxed else 1
-        kw = {"k": k, "exact": exact}
-        with pytest.raises(ValueError, match="k must be a positive integer*"):
+        kw = {"k": k, "exact": exact, "extend": extend}
+        if extend == "zero":
+            msg = "In order to use non-integer.*"
+            if _is_subdtype(type(k), "i") and (k < 1):
+                msg = "For `extend='zero'`.*"
+            with pytest.raises(ValueError, match=msg):
+                special.factorialk(n, **kw)
+        elif k == 0:
+            with pytest.raises(ValueError, match="Parameter k cannot be zero!"):
+                special.factorialk(n, **kw)
+        else:
+            # no error
             special.factorialk(n, **kw)

     @pytest.mark.parametrize("boxed", [True, False])
-    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("exact,extend",
+                             [(True, "zero"), (False, "zero"), (False, "complex")])
     # neither integer, float nor complex
     @pytest.mark.parametrize("k", ["string", np.datetime64("nat")],
                              ids=["string", "NaT"])
-    def test_factorialk_raises_k_other(self, k, exact, boxed):
+    def test_factorialk_raises_k_other(self, k, exact, extend, boxed):
         n = [1] if boxed else 1
-        kw = {"k": k, "exact": exact}
-        with pytest.raises(ValueError, match="k must be a positive integer*"):
+        kw = {"k": k, "exact": exact, "extend": extend}
+        with pytest.raises(ValueError, match="Unsupported data type.*"):
             special.factorialk(n, **kw)

-    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("exact,extend",
+                             [(True, "zero"), (False, "zero"), (False, "complex")])
     @pytest.mark.parametrize("k", range(1, 12))
-    def test_factorialk_dtype(self, k, exact):
-        kw = {"k": k, "exact": exact}
+    def test_factorialk_dtype(self, k, exact, extend):
+        kw = {"k": k, "exact": exact, "extend": extend}
         if exact and k in _FACTORIALK_LIMITS_64BITS.keys():
             n = np.array([_FACTORIALK_LIMITS_32BITS[k]])
             assert_equal(special.factorialk(n, **kw).dtype, np_long)
@@ -2594,7 +2837,7 @@ def test_factorialk_dtype(self, k, exact):
         else:
             n = np.array([_FACTORIALK_LIMITS_64BITS.get(k, 1)])
             # for exact=True and k >= 10, we always return object;
-            # for exact=False it's always float
+            # for exact=False it's always float (unless input is complex)
             dtype = object if exact else np.float64
             assert_equal(special.factorialk(n, **kw).dtype, dtype)

@@ -2602,7 +2845,7 @@ def test_factorial_mixed_nan_inputs(self):
         x = np.array([np.nan, 1, 2, 3, np.nan])
         expected = np.array([np.nan, 1, 2, 6, np.nan])
         assert_equal(special.factorial(x, exact=False), expected)
-        with pytest.raises(ValueError, match="factorial with `exact=True.*"):
+        with pytest.raises(ValueError, match="`exact=True` only supports.*"):
             special.factorial(x, exact=True)

ENH: extensions of `factorial{,2,k}` for complex domains, recurrences, ...
Following the clean-up of #15600 in #15841, we can now discuss the extensions separately.

## Design Space

There are several dimensions that come into play:
- extensions to negative integers
- extensions to floats
- extensions to complex numbers
- extensions to poles through recurrence (possible e.g. for `factorial2`)

## Extension Issues

Some people would like to have the float/complex extensions of these functions (e..g. https://github.com/scipy/scipy/pull/15349). I tend to agree, but that raises another problem:

#### Complex extensions are incompatible with current inputs

First off, everything that's 0 currently on the negative axis gets a different value. More importantly, all even numbers get smaller by a factor ~0.79=sqrt(2/pi) in the [extension](https://en.wikipedia.org/wiki/Double_factorial#Complex_arguments).

For non-complex floats, there doesn't seem to be another relevant extension, especially not one that matches on all the integers and isn't just a gratuitous hack (this is what #15841 ended up stalling on for a long while). Compared to #15600 I now propose that floats continue to be treated as errors for `extend='zero'` in `factorial{2,k}`.

#### Extensions based on recurrence are incompatible with current status as well as complex extension

This came up for example in #15299. It's [possible](https://en.wikipedia.org/wiki/Double_factorial#Negative_arguments) (for example) to invert the recurrence relation n!! = n * (n-2)!!, and so extend the double-factorial to negative integers. The resulting value would be neither 0 (currently) nor the pole in the complex extension.

## Design Considerations

- Complex extensions  (together with non-positive floats) should be behind a keyword, roughly because:
  - 0-for-negative-values is a valid approach, and has been in scipy ~forever.
  - Since the values being produced are incompatible (for negative & even integers), the complex extension should be explicitly opt-in.
- Using recurrence relations for extending into poles is an open question; not sure if worth it, but if so, probably with another enum value for a potential `extend` keyword.
- Use the most general approximation, except when alternatives with better accuracy / performance exist.

## Possible Future Behaviour (note `extend=`, not `exact=`)

| Inputs | `factorial(`
`extend=`
`'zero')` | `factorial(`
`extend=`
`'complex')` | `factorial2(`
`extend=`
`'zero')` | `factorial2(`
`extend=`
`'complex')` | `factorialk(`
`extend=`
`'zero')` | `factorialk(`
`extend=`
`'complex')` |
|-|-|-|-|-|-|-|
| Scalar positive integer w/ `exact=True` | n! | n! | n!! | n!! | n!(k) | n!(k) |
| Scalar positive integer | Γ(n+1) | Γ(n+1) | [1a] for odd,
[1b] for even
(matches n!!) | [1a] (_doesn't_
match n!!
for even n) | n!(k)
(no approx.) | [2] |
| Scalar negative integer / poles | 0.0 | NaN | 0.0 | NaN | 0.0 | NaN |
| Scalar positive float | Γ(n+1) | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| Scalar negative float | 0.0 | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| Scalar complex | `ValueError` | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| Scalar NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| Positive integer in array | Γ(n+1) | Γ(n+1) |[1a] for odd,
[1b] for even
(matches n!!) | [1a] (_doesn't_
match n!!
for even n) | n!(k)
(no approx.) | [2] |
| Negative integer in array | 0.0 | NaN | 0.0 | NaN | 0.0 | NaN |
| Positive float in array | Γ(n+1) | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| Negative float in array | 0.0 | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| Complex in array | `ValueError` | Γ(n+1) | `ValueError` | [1a] | `ValueError` | [2] |
| NaN in array | NaN | NaN | NaN | NaN | NaN | NaN |
| Empty array | `array([])` | `array([])` | `array([])` | `array([])` | `array([])` | `array([])` |

The formulas in question are:
* [1a]: `z!! = 2^((z-1)/2) Γ(z/2 + 1) / Γ(1/2 + 1) == 2^(z/2) Γ(z/2 + 1) sqrt(2 / π)` (holds exactly for odd integers and is the basis for the extension)
* [1b]: `z!! = 2^(z/2) Γ(z/2 + 1)` (holds only for even integers)
* [2]: `z!(α) =  α^((z-1)/α) Γ(z/α + 1) / Γ(1/α + 1)` (extension based on α'th integers)

I'd very much welcome feedback on this - there are some trade-offs to be made (e.g. whether negative floats should also raises without `extend='complex'`, etc.)

Not a general comment, but just a remark on one specific case.

In SciPy-1.10.*, the result of `factorial2(-1)` was `1` and this changed to `0` in version 1.11, which was an unexpected change that broke a library I'm involved in. We relied on the old behavior for the implementation of integrals involving products of Gaussian functions. In the recurrence relations for these integrals, `factorial2(2*n-1)` often appears in denominators, where `n` can be zero or a positive integer.

In case of the double factorial, it is common to define a meaningful result for negative odd integer arguments, see e.g. https://en.wikipedia.org/wiki/Double_factorial#Negative_arguments. Is there any chance of supporting odd negative arguments in `factorial2`? (Note that this is not related to complex arguments, but rather comes from a generalization of the standard recurrence relation.)

> Is there any chance of supporting odd negative arguments in `factorial2`?

Yes it's possible; I mentioned this in the OP already:🙃

> This came up for example in #15299. It's [possible](https://en.wikipedia.org/wiki/Double_factorial#Negative_arguments) (for example) to invert the recurrence relation n!! = n * (n-2)!!, and so extend the double-factorial to negative integers. The resulting value would be neither 0 (currently) nor the pole in the complex extension.

Personally I was waiting for 1.12 to branch before touching the factorial functions again, but feel free to raise a PR!
Sorry for having completely missed your point. I should have gone for that coffee first. The reason I didn't get it, is that in the rows "Scalar negative integer / poles" and "Negative integer in array" contain 0.0 or NaN. Shouldn't these follow the same rules? Letting the outcome depend on the dtype of the argument may cause a lot of confusion.

Do you suggest a PR for a 1.11.x version in which support for negative odd arguments is added to `factorial2` without changing the API? Or do you prefer to go for the API change right away?

P.S. I'm not convinced by the backward compatibility argument to treat integers differently. With 1.11, backward compatibility is already broken.
What I'm suggesting - as described in the OP (so you picked the right issue!) - is to add a keyword argument `extend=` to `factorial2` that allows _opting into_ a different behaviour for `n<0`. In your case, using the values that arise from the recurrence relation. Independently of that, we'll also want to eventually allow using the complex extension, which the design allows without further overhauls.

> P.S. I'm not convinced by the backward compatibility argument to treat integers differently. With 1.11, backward compatibility is already broken.

Yes, it was breakage we accepted (perhaps wrongly without a deprecation period), see https://github.com/scipy/scipy/issues/18813
Thanks for clarifying, and sorry for having missed some points. Just to avoid confusion, I'd rather ask a few more questions:
- The proposal is to have `extend="zero"` as the default value, because this is the current behavior?
- For our use case, the option `extend="recurrence"` would need to be implemented in `factorial2`? (It's not in the table, but this seems to make sense.)
- The `extend="recurrence"` is only meaningful for (some) integer arguments of `factorial2`. Would you return `NaN` when it is combined with a `float` or `complex` argument, or when a negative even integer argument is given?
> The proposal is to have `extend="zero"` as the default value, because this is the current behavior?

Yes

> For our use case, the option `extend="recurrence"` would need to be implemented in `factorial2`?

Yes

> The `extend="recurrence"` is only meaningful for (some) integer arguments of `factorial2`. Would you return `NaN` when it is combined with a `float` or `complex` argument, or when a negative even integer argument is given?

Raising an error for known-to-be-incompatible values is the best approach IMO.


diff --git a/pandas/core/arrays/interval.py b/pandas/core/arrays/interval.py
index f47ef095a8409..bbbf1d9ca60bd 100644
--- a/pandas/core/arrays/interval.py
+++ b/pandas/core/arrays/interval.py
@@ -1055,7 +1055,9 @@ def shift(self, periods: int = 1, fill_value: object = None) -> IntervalArray:
             from pandas import Index

             fill_value = Index(self._left, copy=False)._na_value
-            empty = IntervalArray.from_breaks([fill_value] * (empty_len + 1))
+            empty = IntervalArray.from_breaks(
+                [fill_value] * (empty_len + 1), closed=self.closed
+            )
         else:
             empty = self._from_sequence([fill_value] * empty_len, dtype=self.dtype)

diff --git a/pandas/tests/frame/methods/test_shift.py b/pandas/tests/frame/methods/test_shift.py
index a0f96ff111444..b52240c208493 100644
--- a/pandas/tests/frame/methods/test_shift.py
+++ b/pandas/tests/frame/methods/test_shift.py
@@ -757,3 +757,12 @@ def test_shift_with_offsets_freq_empty(self):
         df_shifted = DataFrame(index=shifted_dates)
         result = df.shift(freq=offset)
         tm.assert_frame_equal(result, df_shifted)
+
+    def test_series_shift_interval_preserves_closed(self):
+        # GH#60389
+        ser = Series(
+            [pd.Interval(1, 2, closed="right"), pd.Interval(2, 3, closed="right")]
+        )
+        result = ser.shift(1)
+        expected = Series([np.nan, pd.Interval(1, 2, closed="right")])
+        tm.assert_series_equal(result, expected)

BUG: Cannot `shift` Intervals that are not `closed='right'` (the default)
### Pandas version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the [latest version](https://pandas.pydata.org/docs/whatsnew/index.html) of pandas.

- [X] I have confirmed this bug exists on the [main branch](https://pandas.pydata.org/docs/dev/getting_started/install.html#installing-the-development-version-of-pandas) of pandas.

### Reproducible Example

```python
import pandas as pd
pd.Series([pd.Interval(1, 2, closed='left'), pd.Interval(2, 3, closed='left')]).shift(1)
```

### Issue Description

[This line](https://github.com/pandas-dev/pandas/blob/1c986d6213904fd7d9acc5622dc91d029d3f1218/pandas/core/arrays/interval.py#L1058) in `shift` creates an empty `IntervalArray` without specifying which side the intervals are closed on. When that array and the one being shifted get concatenated, the following exception is raised:

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/generic.py", line 11228, in shift
    new_data = self._mgr.shift(periods=periods, fill_value=fill_value)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/internals/base.py", line 312, in shift
    return self.apply_with_block("shift", periods=periods, fill_value=fill_value)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/internals/managers.py", line 363, in apply
    applied = getattr(b, f)(**kwargs)
              ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/internals/blocks.py", line 2020, in shift
    new_values = self.values.T.shift(periods=periods, fill_value=fill_value).T
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/arrays/interval.py", line 1097, in shift
    return self._concat_same_type([a, b])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pandas/core/arrays/interval.py", line 1045, in _concat_same_type
    raise ValueError("Intervals must all be closed on the same side.")
ValueError: Intervals must all be closed on the same side.
```

### Expected Behavior

The following `pd.Series[Interval]` should be returned, closed on the same side as the original Series

```
0           NaN
1    [1.0, 2.0)
dtype: interval
```

### Installed Versions

<details>

INSTALLED VERSIONS
------------------
commit                : 0691c5cf90477d3503834d983f69350f250a6ff7
python                : 3.11.5
python-bits           : 64
OS                    : Linux
OS-release            : 6.8.0-48-generic
Version               : #48~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Oct  7 11:24:13 UTC 2
machine               : x86_64
processor             : x86_64
byteorder             : little
LC_ALL                : None
LANG                  : en_US.UTF-8
LOCALE                : en_US.UTF-8

pandas                : 2.2.3
numpy                 : 2.1.3
pytz                  : 2024.2
dateutil              : 2.9.0.post0
pip                   : 24.3.1
Cython                : None
sphinx                : None
IPython               : None
adbc-driver-postgresql: None
adbc-driver-sqlite    : None
bs4                   : 4.12.3
blosc                 : None
bottleneck            : None
dataframe-api-compat  : None
fastparquet           : None
fsspec                : None
html5lib              : None
hypothesis            : None
gcsfs                 : None
jinja2                : 3.1.4
lxml.etree            : None
matplotlib            : 3.9.2
numba                 : None
numexpr               : None
odfpy                 : None
openpyxl              : 3.1.5
pandas_gbq            : None
psycopg2              : None
pymysql               : None
pyarrow               : None
pyreadstat            : None
pytest                : 8.3.3
python-calamine       : None
pyxlsb                : None
s3fs                  : None
scipy                 : 1.14.1
sqlalchemy            : None
tables                : None
tabulate              : None
xarray                : None
xlrd                  : None
xlsxwriter            : None
zstandard             : None
tzdata                : 2024.2
qtpy                  : None
pyqt5                 : None

</details>

Thanks for the report! Further investigations and PRs to fix are welcome.
take


diff --git a/pandas/core/strings/accessor.py b/pandas/core/strings/accessor.py
index 3cb0e75cfb815..05e1a36877e06 100644
--- a/pandas/core/strings/accessor.py
+++ b/pandas/core/strings/accessor.py
@@ -255,7 +255,9 @@ def _validate(data):
         inferred_dtype = lib.infer_dtype(values, skipna=True)

         if inferred_dtype not in allowed_types:
-            raise AttributeError("Can only use .str accessor with string values!")
+            raise AttributeError(
+                f"Can only use .str accessor with string values, not {inferred_dtype}"
+            )
         return inferred_dtype

     def __getitem__(self, key):

diff --git a/pandas/tests/series/accessors/test_str_accessor.py b/pandas/tests/series/accessors/test_str_accessor.py
index 09d965ef1f322..ff530459b78fb 100644
--- a/pandas/tests/series/accessors/test_str_accessor.py
+++ b/pandas/tests/series/accessors/test_str_accessor.py
@@ -15,7 +15,8 @@ def test_str_attribute(self):

         # str accessor only valid with string values
         ser = Series(range(5))
-        with pytest.raises(AttributeError, match="only use .str accessor"):
+        msg = "Can only use .str accessor with string values, not integer"
+        with pytest.raises(AttributeError, match=msg):
             ser.str.repeat(2)

     def test_str_accessor_updates_on_inplace(self):

ENH: increase verbosity on error
### Feature Type

- [ ] Adding new functionality to pandas

- [X] Changing existing functionality in pandas

- [ ] Removing existing functionality in pandas

### Problem Description

On a specific type error the error causing type is not logged in the error

### Feature Description

By replacing:
`raise AttributeError("Can only use .str accessor with string values!")`
with
`raise AttributeError(f"Can only use .str accessor with string values! Inferred dType: {inferred_dtype}")`
the problem would be solved

Details can be found in [here](https://github.com/pandas-dev/pandas/pull/59649/files)

### Alternative Solutions

Use a debugger and set a break point

### Additional Context

_No response_


diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py
index e55968cf38..7a63798d91 100644
--- a/pylint/checkers/variables.py
+++ b/pylint/checkers/variables.py
@@ -1968,7 +1968,7 @@ def _check_consumer(

     def _report_unfound_name_definition(
         self,
-        node: nodes.NodeNG,
+        node: nodes.Name,
         current_consumer: NamesConsumer,
     ) -> bool:
         """Reports used-before-assignment error when all name definition nodes
@@ -1985,7 +1985,9 @@ def _report_unfound_name_definition(
             return False
         if self._is_variable_annotation_in_function(node):
             return False
-        if self._has_nonlocal_binding(node):
+        if self._has_nonlocal_in_enclosing_frame(
+            node, current_consumer.consumed_uncertain.get(node.name, [])
+        ):
             return False
         if (
             node.name in self._reported_type_checking_usage_scopes
@@ -2375,11 +2377,21 @@ def _maybe_used_and_assigned_at_once(defstmt: _base_nodes.Statement) -> bool:
     def _is_builtin(self, name: str) -> bool:
         return name in self.linter.config.additional_builtins or utils.is_builtin(name)

-    def _has_nonlocal_binding(self, node: nodes.Name) -> bool:
-        """Checks if name node has a nonlocal binding in any enclosing frame."""
+    def _has_nonlocal_in_enclosing_frame(
+        self, node: nodes.Name, uncertain_definitions: list[nodes.NodeNG]
+    ) -> bool:
+        """Check if there is a nonlocal declaration in the nearest frame that encloses
+        both usage and definitions.
+        """
+        defining_frames = {definition.frame() for definition in uncertain_definitions}
         frame = node.frame()
-        while frame:
-            if _is_nonlocal_name(node, frame):
+        is_enclosing_frame = False
+        while frame and not is_enclosing_frame:
+            is_enclosing_frame = all(
+                (frame is defining_frame) or frame.parent_of(defining_frame)
+                for defining_frame in defining_frames
+            )
+            if is_enclosing_frame and _is_nonlocal_name(node, frame):
                 return True
             frame = frame.parent.frame() if frame.parent else None
         return False

diff --git a/tests/functional/u/used/used_before_assignment_nonlocal.py b/tests/functional/u/used/used_before_assignment_nonlocal.py
index 4dc8bbf943..a3d8ca6517 100644
--- a/tests/functional/u/used/used_before_assignment_nonlocal.py
+++ b/tests/functional/u/used/used_before_assignment_nonlocal.py
@@ -121,7 +121,9 @@ def inner():

 def nonlocal_in_outer_frame_ok(callback, condition_a, condition_b):
-    """Nonlocal declared in outer frame, usage and definition in different frames."""
+    """Nonlocal declared in outer frame, usage and definition in different frames,
+    both enclosed in outer frame.
+    """
     def outer():
         nonlocal callback
         if condition_a:
@@ -133,3 +135,31 @@ def inner():
                 def callback():
                     pass
     outer()
+
+
+def nonlocal_in_distant_outer_frame_fail(callback, condition_a, condition_b):
+    """Nonlocal declared in outer frame, both usage and definition immediately enclosed
+    in intermediate frame.
+    """
+    def outer():
+        nonlocal callback
+        def intermediate():
+            if condition_a:
+                def inner():
+                    callback()  # [possibly-used-before-assignment]
+                inner()
+            else:
+                if condition_b:
+                    def callback():
+                        pass
+        intermediate()
+    outer()
+
+
+def nonlocal_after_bad_usage_fail():
+    """Nonlocal declared after used-before-assignment."""
+    num = 1
+    def inner():
+        num = num + 1  # [used-before-assignment]
+        nonlocal num
+    inner()
diff --git a/tests/functional/u/used/used_before_assignment_nonlocal.txt b/tests/functional/u/used/used_before_assignment_nonlocal.txt
index 887985fda2..d48443f375 100644
--- a/tests/functional/u/used/used_before_assignment_nonlocal.txt
+++ b/tests/functional/u/used/used_before_assignment_nonlocal.txt
@@ -7,3 +7,5 @@ used-before-assignment:39:18:39:28:test_fail5:Using variable 'undefined1' before
 used-before-assignment:90:10:90:18:type_annotation_never_gets_value_despite_nonlocal:Using variable 'some_num' before assignment:HIGH
 used-before-assignment:96:14:96:18:inner_function_lacks_access_to_outer_args.inner:Using variable 'args' before assignment:HIGH
 used-before-assignment:117:18:117:21:nonlocal_in_outer_frame_fail.outer.inner:Using variable 'num' before assignment:HIGH
+possibly-used-before-assignment:149:20:149:28:nonlocal_in_distant_outer_frame_fail.outer.intermediate.inner:Possibly using variable 'callback' before assignment:CONTROL_FLOW
+used-before-assignment:163:14:163:17:nonlocal_after_bad_usage_fail.inner:Using variable 'num' before assignment:HIGH

[uba] Fix new false negative from #10034
```
def nonlocal_in_outer_frame_ok(callback, condition_a, condition_b):
    def outer():
        nonlocal callback
        def inner():
            if condition_a:
                def inner2():
                    callback()  # possibly-used-before-assignment?
                inner2()
            else:
                if condition_b:
                    def callback():
                        pass
        inner()
    outer()
```
we should probably raise a message here?

_Originally posted by @zenlyj in https://github.com/pylint-dev/pylint/pull/10034#issuecomment-2453421927_