diff --git a/grudge/op.py b/grudge/op.py
index 0077f4a0726cdee80c6b940f3dd07f4f58b7b7e2..5b7c10f23bcb039ad82ed8c2367af44fb0d36673 100644
--- a/grudge/op.py
+++ b/grudge/op.py
@@ -68,7 +68,7 @@ from meshmode.transform_metadata import FirstAxisIsElementsTag
 from grudge.discretization import DiscretizationCollection
 
 from pytools import keyed_memoize_in
-from pytools.obj_array import obj_array_vectorize, make_obj_array
+from pytools.obj_array import make_obj_array
 
 import numpy as np
 
@@ -265,13 +265,14 @@ def local_grad(
         :class:`~arraycontext.container.ArrayContainer`\ of object arrays.
     """
     dd_in = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
-    from grudge.tools import container_grad
-    return container_grad(
-        dcoll.ambient_dim,
-        partial(_strong_scalar_grad, dcoll, dd_in),
-        lambda v: isinstance(v, DOFArray),
-        vec,
-        nested=nested)
+    from grudge.tools import rec_map_subarrays
+    return rec_map_subarrays(
+        f=partial(_strong_scalar_grad, dcoll, dd_in),
+        in_shape=(),
+        out_shape=(dcoll.ambient_dim,),
+        is_scalar=lambda v: isinstance(v, DOFArray),
+        return_nested=nested,
+        ary=vec)
 
 
 def local_d_dx(
@@ -322,17 +323,15 @@ def local_div(dcoll: DiscretizationCollection, vecs) -> ArrayOrContainerT:
     :returns: a :class:`~meshmode.dof_array.DOFArray` or an
         :class:`~arraycontext.container.ArrayContainer` of them.
     """
-    def component_div(vec):
-        return sum(
+    from grudge.tools import rec_map_subarrays
+    return rec_map_subarrays(
+        f=lambda vec: sum(
             local_d_dx(dcoll, i, vec_i)
-            for i, vec_i in enumerate(vec))
-
-    from grudge.tools import container_div
-    return container_div(
-        dcoll.ambient_dim,
-        component_div,
-        lambda v: isinstance(v, DOFArray),
-        vecs)
+            for i, vec_i in enumerate(vec)),
+        in_shape=(dcoll.ambient_dim,),
+        out_shape=(),
+        is_scalar=lambda v: isinstance(v, DOFArray),
+        ary=vecs)
 
 # }}}
 
@@ -431,13 +430,14 @@ def weak_local_grad(
     else:
         raise TypeError("invalid number of arguments")
 
-    from grudge.tools import container_grad
-    return container_grad(
-        dcoll.ambient_dim,
-        partial(_weak_scalar_grad, dcoll, dd_in),
-        lambda v: isinstance(v, DOFArray),
-        vecs,
-        nested=nested)
+    from grudge.tools import rec_map_subarrays
+    return rec_map_subarrays(
+        f=partial(_weak_scalar_grad, dcoll, dd_in),
+        in_shape=(),
+        out_shape=(dcoll.ambient_dim,),
+        is_scalar=lambda v: isinstance(v, DOFArray),
+        return_nested=nested,
+        ary=vecs)
 
 
 def weak_local_d_dx(dcoll: DiscretizationCollection, *args) -> ArrayOrContainerT:
@@ -537,17 +537,15 @@ def weak_local_div(dcoll: DiscretizationCollection, *args) -> ArrayOrContainerT:
     else:
         raise TypeError("invalid number of arguments")
 
-    def component_div(vec):
-        return sum(
+    from grudge.tools import rec_map_subarrays
+    return rec_map_subarrays(
+        f=lambda vec: sum(
             weak_local_d_dx(dcoll, dd_in, i, vec_i)
-            for i, vec_i in enumerate(vec))
-
-    from grudge.tools import container_div
-    return container_div(
-        dcoll.ambient_dim,
-        component_div,
-        lambda v: isinstance(v, DOFArray),
-        vecs)
+            for i, vec_i in enumerate(vec)),
+        in_shape=(dcoll.ambient_dim,),
+        out_shape=(),
+        is_scalar=lambda v: isinstance(v, DOFArray),
+        ary=vecs)
 
 # }}}
 
diff --git a/grudge/tools.py b/grudge/tools.py
index f6fe9ec496164cd0ffe30d889e90aa1d7cbe1116..72aaae8ddba7ae938c14bac9284fa823fb001bdf 100644
--- a/grudge/tools.py
+++ b/grudge/tools.py
@@ -1,5 +1,7 @@
 """
 .. autofunction:: build_jacobian
+.. autofunction:: map_subarrays
+.. autofunction:: rec_map_subarrays
 """
 
 __copyright__ = "Copyright (C) 2007 Andreas Kloeckner"
@@ -26,6 +28,7 @@ THE SOFTWARE.
 
 import numpy as np
 from pytools import levi_civita
+from functools import partial
 
 
 def is_zero(x):
@@ -235,70 +238,99 @@ def build_jacobian(actx, f, base_state, stepsize):
     return mat
 
 
-# {{{ common derivative "helpers"
+def map_subarrays(f, in_shape, out_shape, ary, *, return_nested=False):
+    """
+    Map a function *f* over a :class:`numpy.ndarray`, applying it to subarrays
+    of shape *in_shape* individually.
 
-def container_div(ambient_dim, component_div, is_scalar, vecs):
-    if not isinstance(vecs, np.ndarray):
-        # vecs is not an object array -> treat as array container
-        return map_array_container(
-            partial(container_div, ambient_dim, component_div, is_scalar), vecs)
+    Example 1: given a function *f* that maps arrays of shape ``(2, 2)`` to scalars
+    and an input array *ary* of shape ``(3, 2, 2)``, the call::
 
-    assert vecs.dtype == object
+        map_subarrays(f, (2, 2), (), ary)
 
-    if vecs.size and not is_scalar(vecs[(0,)*vecs.ndim]):
-        # vecs is an object array containing further object arrays
-        # -> treat as array container
-        return map_array_container(
-            partial(container_div, ambient_dim, component_div, is_scalar), vecs)
+    will produce an array of shape ``(3,)`` containing the results of calling *f* on
+    the 3 subarrays of shape ``(2, 2)`` in *ary*.
+
+    Example 2: given a function *f* that maps arrays of shape ``(2,)`` to arrays of
+    shape ``(2, 2)`` and an input array *ary* of shape ``(3, 2)``, the call::
 
-    if vecs.shape[-1] != ambient_dim:
-        raise ValueError("last/innermost dimension of *vecs* argument doesn't match "
-                "ambient dimension")
+        map_subarrays(f, (2,), (2, 2), ary)
 
-    div_result_shape = vecs.shape[:-1]
+    will produce an array of shape ``(3, 2, 2)`` containing the results of calling
+    *f* on the 3 subarrays of shape ``(2,)`` in *ary*. The call::
 
-    if len(div_result_shape) == 0:
-        return component_div(vecs)
+        map_subarrays(f, (2,), (2, 2), ary, return_nested=True)
+
+    will instead produce an array of shape ``(3,)`` with each entry containing an
+    array of shape ``(2, 2)``.
+
+    :param f: the function to be called.
+    :param in_shape: the shape of any inputs to *f*.
+    :param out_shape: the shape of the result of calling *f* on an array of shape
+        *in_shape*.
+    :param ary: a :class:`numpy.ndarray` instance.
+    :param return_nested: if *out_shape* is nontrivial, this flag indicates whether
+        to return a nested array (containing one entry for each application of *f*),
+        or to return a single, higher-dimensional array.
+
+    :returns: an array with the subarrays of shape *in_shape* replaced with
+        subarrays of shape *out_shape* resulting from the application of *f*.
+    """
+    if not isinstance(ary, np.ndarray):
+        if len(in_shape) != 0:
+            raise ValueError(f"found scalar, expected array of shape {in_shape}")
+        return f(ary)
     else:
-        result = np.zeros(div_result_shape, dtype=object)
-        for idx in np.ndindex(div_result_shape):
-            result[idx] = component_div(vecs[idx])
-        return result
-
-
-def container_grad(ambient_dim, component_grad, is_scalar, vecs, nested):
-    if isinstance(vecs, np.ndarray):
-        # Occasionally, data structures coming from *mirgecom* will
-        # contain empty object arrays as placeholders for fields.
-        # For example, species mass fractions is an empty object array when
-        # running in a single-species configuration.
-        # This hack here ensures that these empty arrays, at the very least,
-        # have their shape updated when applying the gradient operator
-        if vecs.size == 0:
-            return vecs.reshape(vecs.shape + (ambient_dim,))
-
-        # For containers with ndarray data (such as momentum/velocity),
-        # the gradient is matrix-valued, so we compute the gradient for
-        # each component. If requested (via 'not nested'), return a matrix of
-        # derivatives by stacking the results.
-        grad = obj_array_vectorize(
-            lambda el: container_grad(
-                ambient_dim, component_grad, is_scalar, el, nested=nested), vecs)
-        if nested:
-            return grad
+        if (
+                ary.ndim < len(in_shape)
+                or ary.shape[ary.ndim-len(in_shape):] != in_shape):
+            raise ValueError(
+                f"array of shape {ary.shape} is incompatible with function "
+                f"expecting input shape {in_shape}")
+        base_shape = ary.shape[:ary.ndim-len(in_shape)]
+        if len(base_shape) == 0:
+            return f(ary)
         else:
-            return np.stack(grad, axis=0)
+            in_slice = tuple(slice(0, n) for n in in_shape)
+            out_slice = tuple(slice(0, n) for n in out_shape)
+            if return_nested:
+                result = np.empty(base_shape, dtype=object)
+                for idx in np.ndindex(base_shape):
+                    result[idx] = f(ary[idx + in_slice])
+                return result
+            else:
+                result = np.empty(base_shape + out_shape, dtype=object)
+                for idx in np.ndindex(base_shape):
+                    result[idx + out_slice] = f(ary[idx + in_slice])
+                return result
+
+
+def rec_map_subarrays(
+        f, in_shape, out_shape, is_scalar, ary, *, return_nested=False):
+    """
+    Map a function *f* over an object *ary*, applying it to any subarrays
+    of shape *in_shape* individually.
+
+    Array container version of :func:`map_subarrays`.
 
-    if not is_scalar(vecs):
+    :param is_scalar: a function that indicates whether a given object is to be
+        treated as a scalar or not.
+    """
+    def is_array_of_scalars(a):
+        return (
+            isinstance(a, np.ndarray) and a.dtype == object
+            and all(is_scalar(a[idx]) for idx in np.ndindex(a.shape)))
+
+    if is_scalar(ary) or is_array_of_scalars(ary):
+        return map_subarrays(
+            f, in_shape, out_shape, ary, return_nested=return_nested)
+    else:
+        from arraycontext import map_array_container
         return map_array_container(
             partial(
-                container_grad, ambient_dim, component_grad, is_scalar,
-                nested=nested),
-            vecs)
-
-    return component_grad(vecs)
-
-# }}}
+                rec_map_subarrays, f, in_shape, out_shape, is_scalar,
+                return_nested=return_nested),
+            ary)
 
 
 # vim: foldmethod=marker