diff --git a/doc/weather-climate.rst b/doc/weather-climate.rst
index f03dfd14c73..9a92a1842d3 100644
--- a/doc/weather-climate.rst
+++ b/doc/weather-climate.rst
@@ -97,14 +97,25 @@ using the same formatting as the standard `datetime.strftime`_ convention .

 For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports:

-- `Partial datetime string indexing`_ using strictly `ISO 8601-format`_ partial
-  datetime strings:
+- `Partial datetime string indexing`_:

 .. ipython:: python

     da.sel(time="0001")
     da.sel(time=slice("0001-05", "0002-02"))

+.. note::
+
+
+   For specifying full or partial datetime strings in cftime
+   indexing, xarray supports two versions of the `ISO 8601 standard`_, the
+   basic pattern (YYYYMMDDhhmmss) or the extended pattern
+   (YYYY-MM-DDThh:mm:ss), as well as the default cftime string format
+   (YYYY-MM-DD hh:mm:ss).  This is somewhat more restrictive than pandas;
+   in other words, some datetime strings that would be valid for a
+   :py:class:`pandas.DatetimeIndex` are not valid for an
+   :py:class:`~xarray.CFTimeIndex`.
+
 - Access of basic datetime components via the ``dt`` accessor (in this case
   just "year", "month", "day", "hour", "minute", "second", "microsecond",
   "season", "dayofyear", "dayofweek", and "days_in_month"):
@@ -196,5 +207,5 @@ For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports:
    encoded in your data and the dates stored in memory.

 .. _Timestamp-valid range: https://pandas.pydata.org/pandas-docs/stable/timeseries.html#timestamp-limitations
-.. _ISO 8601-format: https://en.wikipedia.org/wiki/ISO_8601
+.. _ISO 8601 standard: https://en.wikipedia.org/wiki/ISO_8601
 .. _partial datetime string indexing: https://pandas.pydata.org/pandas-docs/stable/timeseries.html#partial-string-indexing
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index b48e5a4041f..3a9c3b71ea1 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -45,6 +45,10 @@ New Features
   of :py:class:`DataArray` and :py:class:`Dataset` objects and
   document the new method in :doc:`internals`. (:pull:`4248`).
   By `Justus Magin <https://github.com/keewis>`_.
+- Add support for parsing datetime strings formatted following the default
+  string representation of cftime objects, i.e. YYYY-MM-DD hh:mm:ss, in
+  partial datetime string indexing, as well as :py:meth:`~xarray.cftime_range`
+  (:issue:`4337`). By `Spencer Clark <https://github.com/spencerkclark>`_.
 - :py:meth:`~xarray.DataArray.to_dataframe` and :py:meth:`~xarray.Dataset.to_dataframe`
   now accept a ``dim_order`` parameter allowing to specify the resulting dataframe's
   dimensions order (:issue:`4331`, :pull:`4333`).
diff --git a/xarray/coding/cftimeindex.py b/xarray/coding/cftimeindex.py
index cd57af5c7eb..0e1415079fb 100644
--- a/xarray/coding/cftimeindex.py
+++ b/xarray/coding/cftimeindex.py
@@ -91,22 +91,25 @@ def build_pattern(date_sep=r"\-", datetime_sep=r"T", time_sep=r"\:"):

 _BASIC_PATTERN = build_pattern(date_sep="", time_sep="")
 _EXTENDED_PATTERN = build_pattern()
-_PATTERNS = [_BASIC_PATTERN, _EXTENDED_PATTERN]
+_CFTIME_PATTERN = build_pattern(datetime_sep=" ")
+_PATTERNS = [_BASIC_PATTERN, _EXTENDED_PATTERN, _CFTIME_PATTERN]

-def parse_iso8601(datetime_string):
+def parse_iso8601_like(datetime_string):
     for pattern in _PATTERNS:
         match = re.match(pattern, datetime_string)
         if match:
             return match.groupdict()
-    raise ValueError("no ISO-8601 match for string: %s" % datetime_string)
+    raise ValueError(
+        f"no ISO-8601 or cftime-string-like match for string: {datetime_string}"
+    )

 def _parse_iso8601_with_reso(date_type, timestr):
     import cftime

     default = date_type(1, 1, 1)
-    result = parse_iso8601(timestr)
+    result = parse_iso8601_like(timestr)
     replace = {}

     for attr in ["year", "month", "day", "hour", "minute", "second"]:

diff --git a/xarray/tests/test_cftime_offsets.py b/xarray/tests/test_cftime_offsets.py
index 343e059f53c..3efcf8039c6 100644
--- a/xarray/tests/test_cftime_offsets.py
+++ b/xarray/tests/test_cftime_offsets.py
@@ -970,6 +970,15 @@ def test_rollback(calendar, offset, initial_date_args, partial_expected_date_arg
         False,
         [(1, 1, 1, 1), (1, 1, 2, 1), (1, 1, 3, 1)],
     ),
+    (
+        "0001-01-01 01:00:00",
+        "0001-01-04",
+        None,
+        "D",
+        None,
+        False,
+        [(1, 1, 1, 1), (1, 1, 2, 1), (1, 1, 3, 1)],
+    ),
     (
         "0001-01-01T01:00:00",
         "0001-01-04",
diff --git a/xarray/tests/test_cftimeindex.py b/xarray/tests/test_cftimeindex.py
index 642609ba059..d3e5e051efa 100644
--- a/xarray/tests/test_cftimeindex.py
+++ b/xarray/tests/test_cftimeindex.py
@@ -12,7 +12,7 @@
     _parse_iso8601_with_reso,
     _parsed_string_to_bounds,
     assert_all_valid_date_type,
-    parse_iso8601,
+    parse_iso8601_like,
 )
 from xarray.tests import assert_array_equal, assert_identical

@@ -30,7 +30,7 @@ def date_dict(year=None, month=None, day=None, hour=None, minute=None, second=No
     )

-ISO8601_STRING_TESTS = {
+ISO8601_LIKE_STRING_TESTS = {
     "year": ("1999", date_dict(year="1999")),
     "month": ("199901", date_dict(year="1999", month="01")),
     "month-dash": ("1999-01", date_dict(year="1999", month="01")),
@@ -41,6 +41,10 @@ def date_dict(year=None, month=None, day=None, hour=None, minute=None, second=No
         "1999-01-01T12",
         date_dict(year="1999", month="01", day="01", hour="12"),
     ),
+    "hour-space-separator": (
+        "1999-01-01 12",
+        date_dict(year="1999", month="01", day="01", hour="12"),
+    ),
     "minute": (
         "19990101T1234",
         date_dict(year="1999", month="01", day="01", hour="12", minute="34"),
@@ -49,6 +53,10 @@ def date_dict(year=None, month=None, day=None, hour=None, minute=None, second=No
         "1999-01-01T12:34",
         date_dict(year="1999", month="01", day="01", hour="12", minute="34"),
     ),
+    "minute-space-separator": (
+        "1999-01-01 12:34",
+        date_dict(year="1999", month="01", day="01", hour="12", minute="34"),
+    ),
     "second": (
         "19990101T123456",
         date_dict(
@@ -61,21 +69,27 @@ def date_dict(year=None, month=None, day=None, hour=None, minute=None, second=No
             year="1999", month="01", day="01", hour="12", minute="34", second="56"
         ),
     ),
+    "second-space-separator": (
+        "1999-01-01 12:34:56",
+        date_dict(
+            year="1999", month="01", day="01", hour="12", minute="34", second="56"
+        ),
+    ),
 }

 @pytest.mark.parametrize(
     ("string", "expected"),
-    list(ISO8601_STRING_TESTS.values()),
-    ids=list(ISO8601_STRING_TESTS.keys()),
+    list(ISO8601_LIKE_STRING_TESTS.values()),
+    ids=list(ISO8601_LIKE_STRING_TESTS.keys()),
 )
-def test_parse_iso8601(string, expected):
-    result = parse_iso8601(string)
+def test_parse_iso8601_like(string, expected):
+    result = parse_iso8601_like(string)
     assert result == expected

     with pytest.raises(ValueError):
-        parse_iso8601(string + "3")
-        parse_iso8601(string + ".3")
+        parse_iso8601_like(string + "3")
+        parse_iso8601_like(string + ".3")

 _CFTIME_CALENDARS = [

diff --git a/doc/weather-climate.rst b/doc/weather-climate.rst
index f03dfd14..9a92a184 100644
--- a/doc/weather-climate.rst
+++ b/doc/weather-climate.rst
@@ -97,14 +97,25 @@ using the same formatting as the standard `datetime.strftime`_ convention .

 For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports:

-- `Partial datetime string indexing`_ using strictly `ISO 8601-format`_ partial
-  datetime strings:
+- `Partial datetime string indexing`_:

 .. ipython:: python

     da.sel(time="0001")
     da.sel(time=slice("0001-05", "0002-02"))

+.. note::
+
+
+   For specifying full or partial datetime strings in cftime
+   indexing, xarray supports two versions of the `ISO 8601 standard`_, the
+   basic pattern (YYYYMMDDhhmmss) or the extended pattern
+   (YYYY-MM-DDThh:mm:ss), as well as the default cftime string format
+   (YYYY-MM-DD hh:mm:ss).  This is somewhat more restrictive than pandas;
+   in other words, some datetime strings that would be valid for a
+   :py:class:`pandas.DatetimeIndex` are not valid for an
+   :py:class:`~xarray.CFTimeIndex`.
+
 - Access of basic datetime components via the ``dt`` accessor (in this case
   just "year", "month", "day", "hour", "minute", "second", "microsecond",
   "season", "dayofyear", "dayofweek", and "days_in_month"):
@@ -196,5 +207,5 @@ For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports:
    encoded in your data and the dates stored in memory.

 .. _Timestamp-valid range: https://pandas.pydata.org/pandas-docs/stable/timeseries.html#timestamp-limitations
-.. _ISO 8601-format: https://en.wikipedia.org/wiki/ISO_8601
+.. _ISO 8601 standard: https://en.wikipedia.org/wiki/ISO_8601
 .. _partial datetime string indexing: https://pandas.pydata.org/pandas-docs/stable/timeseries.html#partial-string-indexing

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index b48e5a40..41e3d563 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -45,6 +45,9 @@ New Features
   of :py:class:`DataArray` and :py:class:`Dataset` objects and
   document the new method in :doc:`internals`. (:pull:`4248`).
   By `Justus Magin <https://github.com/keewis>`_.
+- Add support for parsing datetime strings formatted following the default
+  string representation of cftime objects, i.e. YYYY-MM-DD hh:mm:ss, in
+  partial datetime string indexing, as well as :py:meth:`~xarray.cftime_range`
 - :py:meth:`~xarray.DataArray.to_dataframe` and :py:meth:`~xarray.Dataset.to_dataframe`
   now accept a ``dim_order`` parameter allowing to specify the resulting dataframe's
   dimensions order (:issue:`4331`, :pull:`4333`).

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'parse_iso8601_like'}, {'type': 'function', 'name': 'parse_iso8601_like'}]


diff --git a/doc/api-hidden.rst b/doc/api-hidden.rst
index b10271b13f5..5d9a0263c35 100644
--- a/doc/api-hidden.rst
+++ b/doc/api-hidden.rst
@@ -298,6 +298,7 @@
    core.accessor_dt.DatetimeAccessor.is_quarter_start
    core.accessor_dt.DatetimeAccessor.is_year_end
    core.accessor_dt.DatetimeAccessor.is_year_start
+   core.accessor_dt.DatetimeAccessor.isocalendar
    core.accessor_dt.DatetimeAccessor.microsecond
    core.accessor_dt.DatetimeAccessor.minute
    core.accessor_dt.DatetimeAccessor.month
diff --git a/doc/api.rst b/doc/api.rst
index 5e8a2be0ed4..ceab7dcc976 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -475,8 +475,6 @@ Datetimelike properties
    DataArray.dt.second
    DataArray.dt.microsecond
    DataArray.dt.nanosecond
-   DataArray.dt.weekofyear
-   DataArray.dt.week
    DataArray.dt.dayofweek
    DataArray.dt.weekday
    DataArray.dt.weekday_name
@@ -500,6 +498,7 @@ Datetimelike properties

    DataArray.dt.floor
    DataArray.dt.ceil
+   DataArray.dt.isocalendar
    DataArray.dt.round
    DataArray.dt.strftime

@@ -896,3 +895,10 @@ Deprecated / Pending Deprecation
    Dataset.apply
    core.groupby.DataArrayGroupBy.apply
    core.groupby.DatasetGroupBy.apply
+
+.. autosummary::
+   :toctree: generated/
+   :template: autosummary/accessor_attribute.rst
+
+   DataArray.dt.weekofyear
+   DataArray.dt.week
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 9460fc08478..308aa400132 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -23,6 +23,10 @@ v0.16.2 (unreleased)
 Breaking changes
 ~~~~~~~~~~~~~~~~

+- :py:attr:`~core.accessor_dt.DatetimeAccessor.weekofyear` and :py:attr:`~core.accessor_dt.DatetimeAccessor.week`
+  have been deprecated. Use ``DataArray.dt.isocalendar().week``
+  instead (:pull:`4534`). By `Mathias Hauser <https://github.com/mathause>`_,
+  `Maximilian Roos <https://github.com/max-sixty>`_, and `Spencer Clark <https://github.com/spencerkclark>`_.
 - :py:attr:`DataArray.rolling` and :py:attr:`Dataset.rolling` no longer support passing ``keep_attrs``
   via its constructor. Pass ``keep_attrs`` via the applied function, i.e. use
   ``ds.rolling(...).mean(keep_attrs=False)`` instead of ``ds.rolling(..., keep_attrs=False).mean()``
@@ -37,6 +41,10 @@ New Features
   By `Miguel Jimenez <https://github.com/Mikejmnez>`_ and `Wei Ji Leong <https://github.com/weiji14>`_.
 - Unary & binary operations follow the ``keep_attrs`` flag (:issue:`3490`, :issue:`4065`, :issue:`3433`, :issue:`3595`, :pull:`4195`).
   By `Deepak Cherian <https://github.com/dcherian>`_.
+- Added :py:meth:`~core.accessor_dt.DatetimeAccessor.isocalendar()` that returns a Dataset
+  with year, week, and weekday calculated according to the ISO 8601 calendar. Requires
+  pandas version 1.1.0 or greater (:pull:`4534`). By `Mathias Hauser <https://github.com/mathause>`_,
+  `Maximilian Roos <https://github.com/max-sixty>`_, and `Spencer Clark <https://github.com/spencerkclark>`_.
 - :py:meth:`Dataset.to_zarr` now supports a ``region`` keyword for writing to
   limited regions of existing Zarr stores (:pull:`4035`).
   See :ref:`io.zarr.appending` for full details.
diff --git a/xarray/core/accessor_dt.py b/xarray/core/accessor_dt.py
index 96ef0f3b5de..3fc682f8c32 100644
--- a/xarray/core/accessor_dt.py
+++ b/xarray/core/accessor_dt.py
@@ -1,3 +1,6 @@
+import warnings
+from distutils.version import LooseVersion
+
 import numpy as np
 import pandas as pd

@@ -40,6 +43,10 @@ def _access_through_series(values, name):
     if name == "season":
         months = values_as_series.dt.month.values
         field_values = _season_from_months(months)
+    elif name == "isocalendar":
+        # isocalendar returns iso- year, week, and weekday -> reshape
+        field_values = np.array(values_as_series.dt.isocalendar(), dtype=np.int64)
+        return field_values.T.reshape(3, *values.shape)
     else:
         field_values = getattr(values_as_series.dt, name).values
     return field_values.reshape(values.shape)
@@ -72,7 +79,15 @@ def _get_date_field(values, name, dtype):
     if is_duck_dask_array(values):
         from dask.array import map_blocks

-        return map_blocks(access_method, values, name, dtype=dtype)
+        new_axis = chunks = None
+        # isocalendar adds adds an axis
+        if name == "isocalendar":
+            chunks = (3,) + values.chunksize
+            new_axis = 0
+
+        return map_blocks(
+            access_method, values, name, dtype=dtype, new_axis=new_axis, chunks=chunks
+        )
     else:
         return access_method(values, name)

@@ -304,6 +319,33 @@ def strftime(self, date_format):
             result, name="strftime", coords=self._obj.coords, dims=self._obj.dims
         )

+    def isocalendar(self):
+        """Dataset containing ISO year, week number, and weekday.
+
+        Note
+        ----
+        The iso year and weekday differ from the nominal year and weekday.
+        """
+
+        from .dataset import Dataset
+
+        if not is_np_datetime_like(self._obj.data.dtype):
+            raise AttributeError("'CFTimeIndex' object has no attribute 'isocalendar'")
+
+        if LooseVersion(pd.__version__) < "1.1.0":
+            raise AttributeError("'isocalendar' not available in pandas < 1.1.0")
+
+        values = _get_date_field(self._obj.data, "isocalendar", np.int64)
+
+        obj_type = type(self._obj)
+        data_vars = {}
+        for i, name in enumerate(["year", "week", "weekday"]):
+            data_vars[name] = obj_type(
+                values[i], name=name, coords=self._obj.coords, dims=self._obj.dims
+            )
+
+        return Dataset(data_vars)
+
     year = Properties._tslib_field_accessor(
         "year", "The year of the datetime", np.int64
     )
@@ -326,9 +368,26 @@ def strftime(self, date_format):
     nanosecond = Properties._tslib_field_accessor(
         "nanosecond", "The nanoseconds of the datetime", np.int64
     )
-    weekofyear = Properties._tslib_field_accessor(
-        "weekofyear", "The week ordinal of the year", np.int64
-    )
+
+    @property
+    def weekofyear(self):
+        "The week ordinal of the year"
+
+        warnings.warn(
+            "dt.weekofyear and dt.week have been deprecated. Please use "
+            "dt.isocalendar().week instead.",
+            FutureWarning,
+        )
+
+        if LooseVersion(pd.__version__) < "1.1.0":
+            weekofyear = Properties._tslib_field_accessor(
+                "weekofyear", "The week ordinal of the year", np.int64
+            ).fget(self)
+        else:
+            weekofyear = self.isocalendar().week
+
+        return weekofyear
+
     week = weekofyear
     dayofweek = Properties._tslib_field_accessor(
         "dayofweek", "The day of the week with Monday=0, Sunday=6", np.int64

diff --git a/xarray/tests/test_accessor_dt.py b/xarray/tests/test_accessor_dt.py
index ae5b0b6d7cf..984bfc763bc 100644
--- a/xarray/tests/test_accessor_dt.py
+++ b/xarray/tests/test_accessor_dt.py
@@ -1,3 +1,5 @@
+from distutils.version import LooseVersion
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -67,10 +69,48 @@ def setup(self):
         ],
     )
     def test_field_access(self, field):
+
+        if LooseVersion(pd.__version__) >= "1.1.0" and field in ["week", "weekofyear"]:
+            data = self.times.isocalendar()["week"]
+        else:
+            data = getattr(self.times, field)
+
+        expected = xr.DataArray(data, name=field, coords=[self.times], dims=["time"])
+
+        if field in ["week", "weekofyear"]:
+            with pytest.warns(
+                FutureWarning, match="dt.weekofyear and dt.week have been deprecated"
+            ):
+                actual = getattr(self.data.time.dt, field)
+        else:
+            actual = getattr(self.data.time.dt, field)
+
+        assert_equal(expected, actual)
+
+    @pytest.mark.parametrize(
+        "field, pandas_field",
+        [
+            ("year", "year"),
+            ("week", "week"),
+            ("weekday", "day"),
+        ],
+    )
+    def test_isocalendar(self, field, pandas_field):
+
+        if LooseVersion(pd.__version__) < "1.1.0":
+            with raises_regex(
+                AttributeError, "'isocalendar' not available in pandas < 1.1.0"
+            ):
+                self.data.time.dt.isocalendar()[field]
+            return
+
+        # pandas isocalendar has dtypy UInt32Dtype, convert to Int64
+        expected = pd.Int64Index(getattr(self.times.isocalendar(), pandas_field))
         expected = xr.DataArray(
-            getattr(self.times, field), name=field, coords=[self.times], dims=["time"]
+            expected, name=field, coords=[self.times], dims=["time"]
         )
-        actual = getattr(self.data.time.dt, field)
+
+        actual = self.data.time.dt.isocalendar()[field]
         assert_equal(expected, actual)

     def test_strftime(self):
@@ -85,6 +125,7 @@ def test_not_datetime_type(self):
         with raises_regex(TypeError, "dt"):
             nontime_data.time.dt

+    @pytest.mark.filterwarnings("ignore:dt.weekofyear and dt.week have been deprecated")
     @requires_dask
     @pytest.mark.parametrize(
         "field",
@@ -129,6 +170,39 @@ def test_dask_field_access(self, field):
         assert_chunks_equal(actual, dask_times_2d)
         assert_equal(actual.compute(), expected.compute())

+    @requires_dask
+    @pytest.mark.parametrize(
+        "field",
+        [
+            "year",
+            "week",
+            "weekday",
+        ],
+    )
+    def test_isocalendar_dask(self, field):
+        import dask.array as da
+
+        if LooseVersion(pd.__version__) < "1.1.0":
+            with raises_regex(
+                AttributeError, "'isocalendar' not available in pandas < 1.1.0"
+            ):
+                self.data.time.dt.isocalendar()[field]
+            return
+
+        expected = getattr(self.times_data.dt.isocalendar(), field)
+
+        dask_times_arr = da.from_array(self.times_arr, chunks=(5, 5, 50))
+        dask_times_2d = xr.DataArray(
+            dask_times_arr, coords=self.data.coords, dims=self.data.dims, name="data"
+        )
+
+        with raise_if_dask_computes():
+            actual = dask_times_2d.dt.isocalendar()[field]
+
+        assert isinstance(actual.data, da.Array)
+        assert_chunks_equal(actual, dask_times_2d)
+        assert_equal(actual.compute(), expected.compute())
+
     @requires_dask
     @pytest.mark.parametrize(
         "method, parameters",
@@ -347,6 +421,15 @@ def test_field_access(data, field):
     assert_equal(result, expected)

+@requires_cftime
+def test_isocalendar_cftime(data):
+
+    with raises_regex(
+        AttributeError, "'CFTimeIndex' object has no attribute 'isocalendar'"
+    ):
+        data.time.dt.isocalendar()
+
+
 @requires_cftime
 @pytest.mark.filterwarnings("ignore::RuntimeWarning")
 def test_cftime_strftime_access(data):

diff --git a/doc/api-hidden.rst b/doc/api-hidden.rst
index b10271b1..5d9a0263 100644
--- a/doc/api-hidden.rst
+++ b/doc/api-hidden.rst
@@ -298,6 +298,7 @@
    core.accessor_dt.DatetimeAccessor.is_quarter_start
    core.accessor_dt.DatetimeAccessor.is_year_end
    core.accessor_dt.DatetimeAccessor.is_year_start
+   core.accessor_dt.DatetimeAccessor.isocalendar
    core.accessor_dt.DatetimeAccessor.microsecond
    core.accessor_dt.DatetimeAccessor.minute
    core.accessor_dt.DatetimeAccessor.month

diff --git a/doc/api.rst b/doc/api.rst
index 5e8a2be0..ceab7dcc 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -475,8 +475,6 @@ Datetimelike properties
    DataArray.dt.second
    DataArray.dt.microsecond
    DataArray.dt.nanosecond
-   DataArray.dt.weekofyear
-   DataArray.dt.week
    DataArray.dt.dayofweek
    DataArray.dt.weekday
    DataArray.dt.weekday_name
@@ -500,6 +498,7 @@ Datetimelike properties

    DataArray.dt.floor
    DataArray.dt.ceil
+   DataArray.dt.isocalendar
    DataArray.dt.round
    DataArray.dt.strftime

@@ -896,3 +895,10 @@ Deprecated / Pending Deprecation
    Dataset.apply
    core.groupby.DataArrayGroupBy.apply
    core.groupby.DatasetGroupBy.apply
+
+.. autosummary::
+   :toctree: generated/
+   :template: autosummary/accessor_attribute.rst
+
+   DataArray.dt.weekofyear
+   DataArray.dt.week

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 9460fc08..6d4dd154 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -23,6 +23,9 @@ v0.16.2 (unreleased)
 Breaking changes
 ~~~~~~~~~~~~~~~~

+- :py:attr:`~core.accessor_dt.DatetimeAccessor.weekofyear` and :py:attr:`~core.accessor_dt.DatetimeAccessor.week`
+  have been deprecated. Use ``DataArray.dt.isocalendar().week``
+  instead.
 - :py:attr:`DataArray.rolling` and :py:attr:`Dataset.rolling` no longer support passing ``keep_attrs``
   via its constructor. Pass ``keep_attrs`` via the applied function, i.e. use
   ``ds.rolling(...).mean(keep_attrs=False)`` instead of ``ds.rolling(..., keep_attrs=False).mean()``
@@ -37,6 +40,10 @@ New Features
   By `Miguel Jimenez <https://github.com/Mikejmnez>`_ and `Wei Ji Leong <https://github.com/weiji14>`_.
 - Unary & binary operations follow the ``keep_attrs`` flag (:issue:`3490`, :issue:`4065`, :issue:`3433`, :issue:`3595`, :pull:`4195`).
   By `Deepak Cherian <https://github.com/dcherian>`_.
+- Added :py:meth:`~core.accessor_dt.DatetimeAccessor.isocalendar()` that returns a Dataset
+  with year, week, and weekday calculated according to the ISO 8601 calendar. Requires
+  pandas version 1.1.0 or greater (:pull:`4534`). By `Mathias Hauser <https://github.com/mathause>`_,
+  `Maximilian Roos <https://github.com/max-sixty>`_, and `Spencer Clark <https://github.com/spencerkclark>`_.
 - :py:meth:`Dataset.to_zarr` now supports a ``region`` keyword for writing to
   limited regions of existing Zarr stores (:pull:`4035`).
   See :ref:`io.zarr.appending` for full details.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'LooseVersion'}, {'type': 'field', 'name': 'distutils'}, {'type': 'field', 'name': 'FutureWarning'}, {'type': 'field', 'name': '__version__'}, {'type': 'field', 'name': 'AttributeError'}]


diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index ebbfc87b4d3..04fd567941f 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -99,6 +99,9 @@ New Features
   :py:func:`xarray.open_mfdataset` when `combine='by_coords'` is specified.
   Fixes (:issue:`5230`).
   By `Tom Nicholas <https://github.com/TomNicholas>`_.
+- Implement ``__setitem__`` for :py:class:`core.indexing.DaskIndexingAdapter` if
+  dask version supports item assignment. (:issue:`5171`, :pull:`5174`)
+  By `Tammas Loughran <https://github.com/tammasloughran>`_.

 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/xarray/core/indexing.py b/xarray/core/indexing.py
index 6839088b099..f4f76efdcf7 100644
--- a/xarray/core/indexing.py
+++ b/xarray/core/indexing.py
@@ -4,11 +4,19 @@
 from collections import defaultdict
 from contextlib import suppress
 from datetime import timedelta
+from distutils.version import LooseVersion
 from typing import Any, Callable, Iterable, List, Sequence, Tuple, Union

 import numpy as np
 import pandas as pd

+try:
+    import dask
+
+    DASK_VERSION = LooseVersion(dask.__version__)
+except ModuleNotFoundError:
+    DASK_VERSION = LooseVersion("0")
+
 from . import duck_array_ops, nputils, utils
 from .npcompat import DTypeLike
 from .pycompat import (
@@ -1380,13 +1388,29 @@ def __getitem__(self, key):
                 return value

     def __setitem__(self, key, value):
-        raise TypeError(
-            "this variable's data is stored in a dask array, "
-            "which does not support item assignment. To "
-            "assign to this variable, you must first load it "
-            "into memory explicitly using the .load() "
-            "method or accessing its .values attribute."
-        )
+        if DASK_VERSION >= "2021.04.0+17":
+            if isinstance(key, BasicIndexer):
+                self.array[key.tuple] = value
+            elif isinstance(key, VectorizedIndexer):
+                self.array.vindex[key.tuple] = value
+            elif isinstance(key, OuterIndexer):
+                num_non_slices = sum(
+                    0 if isinstance(k, slice) else 1 for k in key.tuple
+                )
+                if num_non_slices > 1:
+                    raise NotImplementedError(
+                        "xarray can't set arrays with multiple "
+                        "array indices to dask yet."
+                    )
+                self.array[key.tuple] = value
+        else:
+            raise TypeError(
+                "This variable's data is stored in a dask array, "
+                "and the installed dask version does not support item "
+                "assignment. To assign to this variable, you must either upgrade dask or"
+                "first load the variable into memory explicitly using the .load() "
+                "method or accessing its .values attribute."
+            )

     def transpose(self, order):
         return self.array.transpose(order)

diff --git a/xarray/tests/test_dask.py b/xarray/tests/test_dask.py
index 032e81737e1..8b875662ab8 100644
--- a/xarray/tests/test_dask.py
+++ b/xarray/tests/test_dask.py
@@ -110,8 +110,39 @@ def test_indexing(self):
         self.assertLazyAndIdentical(u[0], v[0])
         self.assertLazyAndIdentical(u[:1], v[:1])
         self.assertLazyAndIdentical(u[[0, 1], [0, 1, 2]], v[[0, 1], [0, 1, 2]])
-        with pytest.raises(TypeError, match=r"stored in a dask array"):
-            v[:1] = 0
+        if LooseVersion(dask.__version__) >= LooseVersion("2021.04.0+17"):
+            # TODO: use @pytest.mark.parametrize to parametrize this
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([99, 2, 3, 4]))
+            arr[0] = 99  # Indexing by integers
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([99, 99, 99, 4]))
+            arr[2::-1] = 99  # Indexing by slices
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([99, 99, 3, 99]))
+            arr[[0, -1, 1]] = 99  # Indexing by a list of integers
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([99, 99, 99, 4]))
+            arr[np.arange(3)] = 99  # Indexing by a 1-d numpy array of integers
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([1, 99, 99, 99]))
+            arr[[False, True, True, True]] = 99  # Indexing by a list of booleans
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([1, 99, 99, 99]))
+            arr[np.arange(4) > 0] = 99  # Indexing by a 1-d numpy array of booleans
+            assert_identical(arr, expected)
+            arr = Variable(("x"), da.array([1, 2, 3, 4]))
+            expected = Variable(("x"), da.array([99, 99, 99, 99]))
+            arr[arr > 0] = 99  # Indexing by one broadcastable Array of booleans
+            assert_identical(arr, expected)
+        else:
+            with pytest.raises(TypeError, match=r"stored in a dask array"):
+                v[:1] = 0

     def test_squeeze(self):
         u = self.eager_var

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index ebbfc87b..924444f3 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -99,6 +99,8 @@ New Features
   :py:func:`xarray.open_mfdataset` when `combine='by_coords'` is specified.
   Fixes (:issue:`5230`).
   By `Tom Nicholas <https://github.com/TomNicholas>`_.
+- Implement ``__setitem__`` for :py:class:`core.indexing.DaskIndexingAdapter` if
+  dask version supports item assignment.

 Breaking changes
 ~~~~~~~~~~~~~~~~


diff --git a/doc/api.rst b/doc/api.rst
index a140d9e2b81..6288ce01803 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -179,6 +179,7 @@ Computation
    Dataset.integrate
    Dataset.map_blocks
    Dataset.polyfit
+   Dataset.curvefit

 **Aggregation**:
 :py:attr:`~Dataset.all`
@@ -375,7 +376,7 @@ Computation
    DataArray.integrate
    DataArray.polyfit
    DataArray.map_blocks
-
+   DataArray.curvefit

 **Aggregation**:
 :py:attr:`~DataArray.all`
diff --git a/doc/user-guide/computation.rst b/doc/user-guide/computation.rst
index 28804bcba6f..2a894f72fdf 100644
--- a/doc/user-guide/computation.rst
+++ b/doc/user-guide/computation.rst
@@ -444,6 +444,89 @@ The inverse operation is done with :py:meth:`~xarray.polyval`,
 .. note::
     These methods replicate the behaviour of :py:func:`numpy.polyfit` and :py:func:`numpy.polyval`.

+
+.. _compute.curvefit:
+
+Fitting arbitrary functions
+===========================
+
+Xarray objects also provide an interface for fitting more complex functions using
+:py:meth:`scipy.optimize.curve_fit`. :py:meth:`~xarray.DataArray.curvefit` accepts
+user-defined functions and can fit along multiple coordinates.
+
+For example, we can fit a relationship between two ``DataArray`` objects, maintaining
+a unique fit at each spatial coordinate but aggregating over the time dimension:
+
+.. ipython:: python
+
+    def exponential(x, a, xc):
+        return np.exp((x - xc) / a)
+
+
+    x = np.arange(-5, 5, 0.1)
+    t = np.arange(-5, 5, 0.1)
+    X, T = np.meshgrid(x, t)
+    Z1 = np.random.uniform(low=-5, high=5, size=X.shape)
+    Z2 = exponential(Z1, 3, X)
+    Z3 = exponential(Z1, 1, -X)
+
+    ds = xr.Dataset(
+        data_vars=dict(
+            var1=(["t", "x"], Z1), var2=(["t", "x"], Z2), var3=(["t", "x"], Z3)
+        ),
+        coords={"t": t, "x": x},
+    )
+    ds[["var2", "var3"]].curvefit(
+        coords=ds.var1,
+        func=exponential,
+        reduce_dims="t",
+        bounds={"a": (0.5, 5), "xc": (-5, 5)},
+    )
+
+We can also fit multi-dimensional functions, and even use a wrapper function to
+simultaneously fit a summation of several functions, such as this field containing
+two gaussian peaks:
+
+.. ipython:: python
+
+    def gaussian_2d(coords, a, xc, yc, xalpha, yalpha):
+        x, y = coords
+        z = a * np.exp(
+            -np.square(x - xc) / 2 / np.square(xalpha)
+            - np.square(y - yc) / 2 / np.square(yalpha)
+        )
+        return z
+
+
+    def multi_peak(coords, *args):
+        z = np.zeros(coords[0].shape)
+        for i in range(len(args) // 5):
+            z += gaussian_2d(coords, *args[i * 5 : i * 5 + 5])
+        return z
+
+
+    x = np.arange(-5, 5, 0.1)
+    y = np.arange(-5, 5, 0.1)
+    X, Y = np.meshgrid(x, y)
+
+    n_peaks = 2
+    names = ["a", "xc", "yc", "xalpha", "yalpha"]
+    names = [f"{name}{i}" for i in range(n_peaks) for name in names]
+    Z = gaussian_2d((X, Y), 3, 1, 1, 2, 1) + gaussian_2d((X, Y), 2, -1, -2, 1, 1)
+    Z += np.random.normal(scale=0.1, size=Z.shape)
+
+    da = xr.DataArray(Z, dims=["y", "x"], coords={"y": y, "x": x})
+    da.curvefit(
+        coords=["x", "y"],
+        func=multi_peak,
+        param_names=names,
+        kwargs={"maxfev": 10000},
+    )
+
+.. note::
+    This method replicates the behavior of :py:func:`scipy.optimize.curve_fit`.
+
+
 .. _compute.broadcasting:

 Broadcasting by dimension name
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 408b59d3c6a..5e394236096 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -65,6 +65,8 @@ New Features
   :py:meth:`~pandas.core.groupby.GroupBy.get_group`.
   By `Deepak Cherian <https://github.com/dcherian>`_.
 - Disable the `cfgrib` backend if the `eccodes` library is not installed (:pull:`5083`). By `Baudouin Raoult <https://github.com/b8raoult>`_.
+- Added :py:meth:`DataArray.curvefit` and :py:meth:`Dataset.curvefit` for general curve fitting applications. (:issue:`4300`, :pull:`4849`)
+  By `Sam Levang <https://github.com/slevang>`_.

 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index 1f82b48d7f8..3b15144df4e 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -4418,6 +4418,84 @@ def query(
         )
         return ds[self.name]

+    def curvefit(
+        self,
+        coords: Union[Union[str, "DataArray"], Iterable[Union[str, "DataArray"]]],
+        func: Callable[..., Any],
+        reduce_dims: Union[Hashable, Iterable[Hashable]] = None,
+        skipna: bool = True,
+        p0: Dict[str, Any] = None,
+        bounds: Dict[str, Any] = None,
+        param_names: Sequence[str] = None,
+        kwargs: Dict[str, Any] = None,
+    ):
+        """
+        Curve fitting optimization for arbitrary functions.
+
+        Wraps `scipy.optimize.curve_fit` with `apply_ufunc`.
+
+        Parameters
+        ----------
+        coords : DataArray, str or sequence of DataArray, str
+            Independent coordinate(s) over which to perform the curve fitting. Must share
+            at least one dimension with the calling object. When fitting multi-dimensional
+            functions, supply `coords` as a sequence in the same order as arguments in
+            `func`. To fit along existing dimensions of the calling object, `coords` can
+            also be specified as a str or sequence of strs.
+        func : callable
+            User specified function in the form `f(x, *params)` which returns a numpy
+            array of length `len(x)`. `params` are the fittable parameters which are optimized
+            by scipy curve_fit. `x` can also be specified as a sequence containing multiple
+            coordinates, e.g. `f((x0, x1), *params)`.
+        reduce_dims : str or sequence of str
+            Additional dimension(s) over which to aggregate while fitting. For example,
+            calling `ds.curvefit(coords='time', reduce_dims=['lat', 'lon'], ...)` will
+            aggregate all lat and lon points and fit the specified function along the
+            time dimension.
+        skipna : bool, optional
+            Whether to skip missing values when fitting. Default is True.
+        p0 : dictionary, optional
+            Optional dictionary of parameter names to initial guesses passed to the
+            `curve_fit` `p0` arg. If none or only some parameters are passed, the rest will
+            be assigned initial values following the default scipy behavior.
+        bounds : dictionary, optional
+            Optional dictionary of parameter names to bounding values passed to the
+            `curve_fit` `bounds` arg. If none or only some parameters are passed, the rest
+            will be unbounded following the default scipy behavior.
+        param_names: seq, optional
+            Sequence of names for the fittable parameters of `func`. If not supplied,
+            this will be automatically determined by arguments of `func`. `param_names`
+            should be manually supplied when fitting a function that takes a variable
+            number of parameters.
+        kwargs : dictionary
+            Additional keyword arguments to passed to scipy curve_fit.
+
+        Returns
+        -------
+        curvefit_results : Dataset
+            A single dataset which contains:
+
+            [var]_curvefit_coefficients
+                The coefficients of the best fit.
+            [var]_curvefit_covariance
+                The covariance matrix of the coefficient estimates.
+
+        See also
+        --------
+        DataArray.polyfit
+        scipy.optimize.curve_fit
+        """
+        return self._to_temp_dataset().curvefit(
+            coords,
+            func,
+            reduce_dims=reduce_dims,
+            skipna=skipna,
+            p0=p0,
+            bounds=bounds,
+            param_names=param_names,
+            kwargs=kwargs,
+        )
+
     # this needs to be at the end, or mypy will confuse with `str`
     # https://mypy.readthedocs.io/en/latest/common_issues.html#dealing-with-conflicting-names
     str = utils.UncachedAccessor(StringAccessor)
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index e959135e8d9..8beacbf8c42 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -1,6 +1,7 @@
 import copy
 import datetime
 import functools
+import inspect
 import sys
 import warnings
 from collections import defaultdict
@@ -456,6 +457,63 @@ def as_dataset(obj: Any) -> "Dataset":
     return obj

+def _get_func_args(func, param_names):
+    """Use `inspect.signature` to try accessing `func` args. Otherwise, ensure
+    they are provided by user.
+    """
+    try:
+        func_args = inspect.signature(func).parameters
+    except ValueError:
+        func_args = {}
+        if not param_names:
+            raise ValueError(
+                "Unable to inspect `func` signature, and `param_names` was not provided."
+            )
+    if param_names:
+        params = param_names
+    else:
+        params = list(func_args)[1:]
+        if any(
+            [(p.kind in [p.VAR_POSITIONAL, p.VAR_KEYWORD]) for p in func_args.values()]
+        ):
+            raise ValueError(
+                "`param_names` must be provided because `func` takes variable length arguments."
+            )
+    return params, func_args
+
+
+def _initialize_curvefit_params(params, p0, bounds, func_args):
+    """Set initial guess and bounds for curvefit.
+    Priority: 1) passed args 2) func signature 3) scipy defaults
+    """
+
+    def _initialize_feasible(lb, ub):
+        # Mimics functionality of scipy.optimize.minpack._initialize_feasible
+        lb_finite = np.isfinite(lb)
+        ub_finite = np.isfinite(ub)
+        p0 = np.nansum(
+            [
+                0.5 * (lb + ub) * int(lb_finite & ub_finite),
+                (lb + 1) * int(lb_finite & ~ub_finite),
+                (ub - 1) * int(~lb_finite & ub_finite),
+            ]
+        )
+        return p0
+
+    param_defaults = {p: 1 for p in params}
+    bounds_defaults = {p: (-np.inf, np.inf) for p in params}
+    for p in params:
+        if p in func_args and func_args[p].default is not func_args[p].empty:
+            param_defaults[p] = func_args[p].default
+        if p in bounds:
+            bounds_defaults[p] = tuple(bounds[p])
+            if param_defaults[p] < bounds[p][0] or param_defaults[p] > bounds[p][1]:
+                param_defaults[p] = _initialize_feasible(bounds[p][0], bounds[p][1])
+        if p in p0:
+            param_defaults[p] = p0[p]
+    return param_defaults, bounds_defaults
+
+
 class DataVariables(Mapping[Hashable, "DataArray"]):
     __slots__ = ("_dataset",)

@@ -7074,5 +7132,180 @@ def query(
         # apply the selection
         return self.isel(indexers, missing_dims=missing_dims)

+    def curvefit(
+        self,
+        coords: Union[Union[str, "DataArray"], Iterable[Union[str, "DataArray"]]],
+        func: Callable[..., Any],
+        reduce_dims: Union[Hashable, Iterable[Hashable]] = None,
+        skipna: bool = True,
+        p0: Dict[str, Any] = None,
+        bounds: Dict[str, Any] = None,
+        param_names: Sequence[str] = None,
+        kwargs: Dict[str, Any] = None,
+    ):
+        """
+        Curve fitting optimization for arbitrary functions.
+
+        Wraps `scipy.optimize.curve_fit` with `apply_ufunc`.
+
+        Parameters
+        ----------
+        coords : DataArray, str or sequence of DataArray, str
+            Independent coordinate(s) over which to perform the curve fitting. Must share
+            at least one dimension with the calling object. When fitting multi-dimensional
+            functions, supply `coords` as a sequence in the same order as arguments in
+            `func`. To fit along existing dimensions of the calling object, `coords` can
+            also be specified as a str or sequence of strs.
+        func : callable
+            User specified function in the form `f(x, *params)` which returns a numpy
+            array of length `len(x)`. `params` are the fittable parameters which are optimized
+            by scipy curve_fit. `x` can also be specified as a sequence containing multiple
+            coordinates, e.g. `f((x0, x1), *params)`.
+        reduce_dims : str or sequence of str
+            Additional dimension(s) over which to aggregate while fitting. For example,
+            calling `ds.curvefit(coords='time', reduce_dims=['lat', 'lon'], ...)` will
+            aggregate all lat and lon points and fit the specified function along the
+            time dimension.
+        skipna : bool, optional
+            Whether to skip missing values when fitting. Default is True.
+        p0 : dictionary, optional
+            Optional dictionary of parameter names to initial guesses passed to the
+            `curve_fit` `p0` arg. If none or only some parameters are passed, the rest will
+            be assigned initial values following the default scipy behavior.
+        bounds : dictionary, optional
+            Optional dictionary of parameter names to bounding values passed to the
+            `curve_fit` `bounds` arg. If none or only some parameters are passed, the rest
+            will be unbounded following the default scipy behavior.
+        param_names: seq, optional
+            Sequence of names for the fittable parameters of `func`. If not supplied,
+            this will be automatically determined by arguments of `func`. `param_names`
+            should be manually supplied when fitting a function that takes a variable
+            number of parameters.
+        kwargs : dictionary
+            Additional keyword arguments to passed to scipy curve_fit.
+
+        Returns
+        -------
+        curvefit_results : Dataset
+            A single dataset which contains:
+
+            [var]_curvefit_coefficients
+                The coefficients of the best fit.
+            [var]_curvefit_covariance
+                The covariance matrix of the coefficient estimates.
+
+        See also
+        --------
+        Dataset.polyfit
+        scipy.optimize.curve_fit
+        """
+        from scipy.optimize import curve_fit
+
+        if p0 is None:
+            p0 = {}
+        if bounds is None:
+            bounds = {}
+        if kwargs is None:
+            kwargs = {}
+
+        if not reduce_dims:
+            reduce_dims_ = []
+        elif isinstance(reduce_dims, str) or not isinstance(reduce_dims, Iterable):
+            reduce_dims_ = [reduce_dims]
+        else:
+            reduce_dims_ = list(reduce_dims)
+
+        if (
+            isinstance(coords, str)
+            or isinstance(coords, xr.DataArray)
+            or not isinstance(coords, Iterable)
+        ):
+            coords = [coords]
+        coords_ = [self[coord] if isinstance(coord, str) else coord for coord in coords]
+
+        # Determine whether any coords are dims on self
+        for coord in coords_:
+            reduce_dims_ += [c for c in self.dims if coord.equals(self[c])]
+        reduce_dims_ = list(set(reduce_dims_))
+        preserved_dims = list(set(self.dims) - set(reduce_dims_))
+        if not reduce_dims_:
+            raise ValueError(
+                "No arguments to `coords` were identified as a dimension on the calling "
+                "object, and no dims were supplied to `reduce_dims`. This would result "
+                "in fitting on scalar data."
+            )
+
+        # Broadcast all coords with each other
+        coords_ = xr.broadcast(*coords_)
+        coords_ = [
+            coord.broadcast_like(self, exclude=preserved_dims) for coord in coords_
+        ]
+
+        params, func_args = _get_func_args(func, param_names)
+        param_defaults, bounds_defaults = _initialize_curvefit_params(
+            params, p0, bounds, func_args
+        )
+        n_params = len(params)
+        kwargs.setdefault("p0", [param_defaults[p] for p in params])
+        kwargs.setdefault(
+            "bounds",
+            [
+                [bounds_defaults[p][0] for p in params],
+                [bounds_defaults[p][1] for p in params],
+            ],
+        )
+
+        def _wrapper(Y, *coords_, **kwargs):
+            # Wrap curve_fit with raveled coordinates and pointwise NaN handling
+            x = np.vstack([c.ravel() for c in coords_])
+            y = Y.ravel()
+            if skipna:
+                mask = np.all([np.any(~np.isnan(x), axis=0), ~np.isnan(y)], axis=0)
+                x = x[:, mask]
+                y = y[mask]
+                if not len(y):
+                    popt = np.full([n_params], np.nan)
+                    pcov = np.full([n_params, n_params], np.nan)
+                    return popt, pcov
+            x = np.squeeze(x)
+            popt, pcov = curve_fit(func, x, y, **kwargs)
+            return popt, pcov
+
+        result = xr.Dataset()
+        for name, da in self.data_vars.items():
+            if name is xr.core.dataarray._THIS_ARRAY:
+                name = ""
+            else:
+                name = f"{str(name)}_"
+
+            popt, pcov = xr.apply_ufunc(
+                _wrapper,
+                da,
+                *coords_,
+                vectorize=True,
+                dask="parallelized",
+                input_core_dims=[reduce_dims_ for d in range(len(coords_) + 1)],
+                output_core_dims=[["param"], ["cov_i", "cov_j"]],
+                dask_gufunc_kwargs={
+                    "output_sizes": {
+                        "param": n_params,
+                        "cov_i": n_params,
+                        "cov_j": n_params,
+                    },
+                },
+                output_dtypes=(np.float64, np.float64),
+                exclude_dims=set(reduce_dims_),
+                kwargs=kwargs,
+            )
+            result[name + "curvefit_coefficients"] = popt
+            result[name + "curvefit_covariance"] = pcov
+
+        result = result.assign_coords(
+            {"param": params, "cov_i": params, "cov_j": params}
+        )
+        result.attrs = self.attrs.copy()
+
+        return result
+

 ops.inject_all_ops_and_reduce_methods(Dataset, array_only=False)

diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index 20e357543d6..c18f9acfca1 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -4690,6 +4690,60 @@ def test_query(self, backend, engine, parser):
         with pytest.raises(UndefinedVariableError):
             aa.query(x="spam > 50")  # name not present

+    @requires_scipy
+    @pytest.mark.parametrize("use_dask", [True, False])
+    def test_curvefit(self, use_dask):
+        if use_dask and not has_dask:
+            pytest.skip("requires dask")
+
+        def exp_decay(t, n0, tau=1):
+            return n0 * np.exp(-t / tau)
+
+        t = np.arange(0, 5, 0.5)
+        da = DataArray(
+            np.stack([exp_decay(t, 3, 3), exp_decay(t, 5, 4), np.nan * t], axis=-1),
+            dims=("t", "x"),
+            coords={"t": t, "x": [0, 1, 2]},
+        )
+        da[0, 0] = np.nan
+
+        expected = DataArray(
+            [[3, 3], [5, 4], [np.nan, np.nan]],
+            dims=("x", "param"),
+            coords={"x": [0, 1, 2], "param": ["n0", "tau"]},
+        )
+
+        if use_dask:
+            da = da.chunk({"x": 1})
+
+        fit = da.curvefit(
+            coords=[da.t], func=exp_decay, p0={"n0": 4}, bounds={"tau": [2, 6]}
+        )
+        assert_allclose(fit.curvefit_coefficients, expected, rtol=1e-3)
+
+        da = da.compute()
+        fit = da.curvefit(coords="t", func=np.power, reduce_dims="x", param_names=["a"])
+        assert "a" in fit.param
+        assert "x" not in fit.dims
+
+    def test_curvefit_helpers(self):
+        def exp_decay(t, n0, tau=1):
+            return n0 * np.exp(-t / tau)
+
+        params, func_args = xr.core.dataset._get_func_args(exp_decay, [])
+        assert params == ["n0", "tau"]
+        param_defaults, bounds_defaults = xr.core.dataset._initialize_curvefit_params(
+            params, {"n0": 4}, {"tau": [5, np.inf]}, func_args
+        )
+        assert param_defaults == {"n0": 4, "tau": 6}
+        assert bounds_defaults == {"n0": (-np.inf, np.inf), "tau": (5, np.inf)}
+
+        param_names = ["a"]
+        params, func_args = xr.core.dataset._get_func_args(np.power, param_names)
+        assert params == param_names
+        with pytest.raises(ValueError):
+            xr.core.dataset._get_func_args(np.power, [])
+

 class TestReduce:
     @pytest.fixture(autouse=True)

diff --git a/doc/api.rst b/doc/api.rst
index a140d9e2..6288ce01 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -179,6 +179,7 @@ Computation
    Dataset.integrate
    Dataset.map_blocks
    Dataset.polyfit
+   Dataset.curvefit

 **Aggregation**:
 :py:attr:`~Dataset.all`
@@ -375,7 +376,7 @@ Computation
    DataArray.integrate
    DataArray.polyfit
    DataArray.map_blocks
-
+   DataArray.curvefit

 **Aggregation**:
 :py:attr:`~DataArray.all`

diff --git a/doc/user-guide/computation.rst b/doc/user-guide/computation.rst
index 28804bcb..2a894f72 100644
--- a/doc/user-guide/computation.rst
+++ b/doc/user-guide/computation.rst
@@ -444,6 +444,89 @@ The inverse operation is done with :py:meth:`~xarray.polyval`,
 .. note::
     These methods replicate the behaviour of :py:func:`numpy.polyfit` and :py:func:`numpy.polyval`.

+
+.. _compute.curvefit:
+
+Fitting arbitrary functions
+===========================
+
+Xarray objects also provide an interface for fitting more complex functions using
+:py:meth:`scipy.optimize.curve_fit`. :py:meth:`~xarray.DataArray.curvefit` accepts
+user-defined functions and can fit along multiple coordinates.
+
+For example, we can fit a relationship between two ``DataArray`` objects, maintaining
+a unique fit at each spatial coordinate but aggregating over the time dimension:
+
+.. ipython:: python
+
+    def exponential(x, a, xc):
+        return np.exp((x - xc) / a)
+
+
+    x = np.arange(-5, 5, 0.1)
+    t = np.arange(-5, 5, 0.1)
+    X, T = np.meshgrid(x, t)
+    Z1 = np.random.uniform(low=-5, high=5, size=X.shape)
+    Z2 = exponential(Z1, 3, X)
+    Z3 = exponential(Z1, 1, -X)
+
+    ds = xr.Dataset(
+        data_vars=dict(
+            var1=(["t", "x"], Z1), var2=(["t", "x"], Z2), var3=(["t", "x"], Z3)
+        ),
+        coords={"t": t, "x": x},
+    )
+    ds[["var2", "var3"]].curvefit(
+        coords=ds.var1,
+        func=exponential,
+        reduce_dims="t",
+        bounds={"a": (0.5, 5), "xc": (-5, 5)},
+    )
+
+We can also fit multi-dimensional functions, and even use a wrapper function to
+simultaneously fit a summation of several functions, such as this field containing
+two gaussian peaks:
+
+.. ipython:: python
+
+    def gaussian_2d(coords, a, xc, yc, xalpha, yalpha):
+        x, y = coords
+        z = a * np.exp(
+            -np.square(x - xc) / 2 / np.square(xalpha)
+            - np.square(y - yc) / 2 / np.square(yalpha)
+        )
+        return z
+
+
+    def multi_peak(coords, *args):
+        z = np.zeros(coords[0].shape)
+        for i in range(len(args) // 5):
+            z += gaussian_2d(coords, *args[i * 5 : i * 5 + 5])
+        return z
+
+
+    x = np.arange(-5, 5, 0.1)
+    y = np.arange(-5, 5, 0.1)
+    X, Y = np.meshgrid(x, y)
+
+    n_peaks = 2
+    names = ["a", "xc", "yc", "xalpha", "yalpha"]
+    names = [f"{name}{i}" for i in range(n_peaks) for name in names]
+    Z = gaussian_2d((X, Y), 3, 1, 1, 2, 1) + gaussian_2d((X, Y), 2, -1, -2, 1, 1)
+    Z += np.random.normal(scale=0.1, size=Z.shape)
+
+    da = xr.DataArray(Z, dims=["y", "x"], coords={"y": y, "x": x})
+    da.curvefit(
+        coords=["x", "y"],
+        func=multi_peak,
+        param_names=names,
+        kwargs={"maxfev": 10000},
+    )
+
+.. note::
+    This method replicates the behavior of :py:func:`scipy.optimize.curve_fit`.
+
+
 .. _compute.broadcasting:

 Broadcasting by dimension name

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 408b59d3..f490a54f 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -65,6 +65,7 @@ New Features
   :py:meth:`~pandas.core.groupby.GroupBy.get_group`.
   By `Deepak Cherian <https://github.com/dcherian>`_.
 - Disable the `cfgrib` backend if the `eccodes` library is not installed (:pull:`5083`). By `Baudouin Raoult <https://github.com/b8raoult>`_.
+- Added :py:meth:`DataArray.curvefit` and :py:meth:`Dataset.curvefit` for general curve fitting applications.

 Breaking changes
 ~~~~~~~~~~~~~~~~

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'bounds_defaults'}, {'type': 'field', 'name': '_get_func_args'}, {'type': 'function', 'name': '_get_func_args'}, {'type': 'field', 'name': 'func_args'}, {'type': 'field', 'name': 'param_defaults'}, {'type': 'function', 'name': '_initialize_curvefit_params'}, {'type': 'field', 'name': '_initialize_curvefit_params'}, {'type': 'field', 'name': 'p0'}, {'type': 'field', 'name': 'inf'}, {'type': 'field', 'name': 'p0'}]


diff --git a/ci/requirements/environment.yml b/ci/requirements/environment.yml
index 36147c64c03..57498fa5700 100644
--- a/ci/requirements/environment.yml
+++ b/ci/requirements/environment.yml
@@ -22,6 +22,7 @@ dependencies:
   - nc-time-axis
   - netcdf4
   - numba
+  - numexpr
   - numpy
   - pandas
   - pint
diff --git a/doc/api.rst b/doc/api.rst
index 7e13c2feefe..4af9bb01208 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -138,6 +138,7 @@ Indexing
    Dataset.set_index
    Dataset.reset_index
    Dataset.reorder_levels
+   Dataset.query

 Missing value handling
 ----------------------
@@ -321,6 +322,7 @@ Indexing
    DataArray.set_index
    DataArray.reset_index
    DataArray.reorder_levels
+   DataArray.query

 Missing value handling
 ----------------------
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index e0c11fefd36..fbd2617b16f 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,6 +22,10 @@ v0.17.1 (unreleased)

 New Features
 ~~~~~~~~~~~~
+
+- Add :py:meth:`Dataset.query` and :py:meth:`DataArray.query` which enable indexing
+  of datasets and data arrays by evaluating query expressions against the values of the
+  data variables (:pull:`4984`). By `Alistair Miles <https://github.com/alimanfoo>`_.
 - Allow passing ``combine_attrs`` to :py:meth:`Dataset.merge` (:pull:`4895`).
   By `Justus Magin <https://github.com/keewis>`_.
 - Support for `dask.graph_manipulation
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index 5f944354901..b2fc14f4ba7 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -4354,6 +4354,70 @@ def argmax(
         else:
             return self._replace_maybe_drop_dims(result)

+    def query(
+        self,
+        queries: Mapping[Hashable, Any] = None,
+        parser: str = "pandas",
+        engine: str = None,
+        missing_dims: str = "raise",
+        **queries_kwargs: Any,
+    ) -> "DataArray":
+        """Return a new data array indexed along the specified
+        dimension(s), where the indexers are given as strings containing
+        Python expressions to be evaluated against the values in the array.
+
+        Parameters
+        ----------
+        queries : dict, optional
+            A dict with keys matching dimensions and values given by strings
+            containing Python expressions to be evaluated against the data variables
+            in the dataset. The expressions will be evaluated using the pandas
+            eval() function, and can contain any valid Python expressions but cannot
+            contain any Python statements.
+        parser : {"pandas", "python"}, default: "pandas"
+            The parser to use to construct the syntax tree from the expression.
+            The default of 'pandas' parses code slightly different than standard
+            Python. Alternatively, you can parse an expression using the 'python'
+            parser to retain strict Python semantics.
+        engine: {"python", "numexpr", None}, default: None
+            The engine used to evaluate the expression. Supported engines are:
+            - None: tries to use numexpr, falls back to python
+            - "numexpr": evaluates expressions using numexpr
+            - "python": performs operations as if you had eval’d in top level python
+        missing_dims : {"raise", "warn", "ignore"}, default: "raise"
+            What to do if dimensions that should be selected from are not present in the
+            Dataset:
+            - "raise": raise an exception
+            - "warning": raise a warning, and ignore the missing dimensions
+            - "ignore": ignore the missing dimensions
+        **queries_kwargs : {dim: query, ...}, optional
+            The keyword arguments form of ``queries``.
+            One of queries or queries_kwargs must be provided.
+
+        Returns
+        -------
+        obj : DataArray
+            A new DataArray with the same contents as this dataset, indexed by
+            the results of the appropriate queries.
+
+        See Also
+        --------
+        DataArray.isel
+        Dataset.query
+        pandas.eval
+
+        """
+
+        ds = self._to_dataset_whole(shallow_copy=True)
+        ds = ds.query(
+            queries=queries,
+            parser=parser,
+            engine=engine,
+            missing_dims=missing_dims,
+            **queries_kwargs,
+        )
+        return ds[self.name]
+
     # this needs to be at the end, or mypy will confuse with `str`
     # https://mypy.readthedocs.io/en/latest/common_issues.html#dealing-with-conflicting-names
     str = utils.UncachedAccessor(StringAccessor)
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index d76d136be8b..19b3eec5f07 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -7001,5 +7001,78 @@ def argmax(self, dim=None, **kwargs):
                 "Dataset.argmin() with a sequence or ... for dim"
             )

+    def query(
+        self,
+        queries: Mapping[Hashable, Any] = None,
+        parser: str = "pandas",
+        engine: str = None,
+        missing_dims: str = "raise",
+        **queries_kwargs: Any,
+    ) -> "Dataset":
+        """Return a new dataset with each array indexed along the specified
+        dimension(s), where the indexers are given as strings containing
+        Python expressions to be evaluated against the data variables in the
+        dataset.
+
+        Parameters
+        ----------
+        queries : dict, optional
+            A dict with keys matching dimensions and values given by strings
+            containing Python expressions to be evaluated against the data variables
+            in the dataset. The expressions will be evaluated using the pandas
+            eval() function, and can contain any valid Python expressions but cannot
+            contain any Python statements.
+        parser : {"pandas", "python"}, default: "pandas"
+            The parser to use to construct the syntax tree from the expression.
+            The default of 'pandas' parses code slightly different than standard
+            Python. Alternatively, you can parse an expression using the 'python'
+            parser to retain strict Python semantics.
+        engine: {"python", "numexpr", None}, default: None
+            The engine used to evaluate the expression. Supported engines are:
+            - None: tries to use numexpr, falls back to python
+            - "numexpr": evaluates expressions using numexpr
+            - "python": performs operations as if you had eval’d in top level python
+        missing_dims : {"raise", "warn", "ignore"}, default: "raise"
+            What to do if dimensions that should be selected from are not present in the
+            Dataset:
+            - "raise": raise an exception
+            - "warning": raise a warning, and ignore the missing dimensions
+            - "ignore": ignore the missing dimensions
+        **queries_kwargs : {dim: query, ...}, optional
+            The keyword arguments form of ``queries``.
+            One of queries or queries_kwargs must be provided.
+
+        Returns
+        -------
+        obj : Dataset
+            A new Dataset with the same contents as this dataset, except each
+            array and dimension is indexed by the results of the appropriate
+            queries.
+
+        See Also
+        --------
+        Dataset.isel
+        pandas.eval
+
+        """
+
+        # allow queries to be given either as a dict or as kwargs
+        queries = either_dict_or_kwargs(queries, queries_kwargs, "query")
+
+        # check queries
+        for dim, expr in queries.items():
+            if not isinstance(expr, str):
+                msg = f"expr for dim {dim} must be a string to be evaluated, {type(expr)} given"
+                raise ValueError(msg)
+
+        # evaluate the queries to create the indexers
+        indexers = {
+            dim: pd.eval(expr, resolvers=[self], parser=parser, engine=engine)
+            for dim, expr in queries.items()
+        }
+
+        # apply the selection
+        return self.isel(indexers, missing_dims=missing_dims)
+

 ops.inject_all_ops_and_reduce_methods(Dataset, array_only=False)

diff --git a/xarray/tests/__init__.py b/xarray/tests/__init__.py
index 4b47e1d2c7e..aebcb0f2b8d 100644
--- a/xarray/tests/__init__.py
+++ b/xarray/tests/__init__.py
@@ -83,6 +83,7 @@ def LooseVersion(vstring):
 has_cartopy, requires_cartopy = _importorskip("cartopy")
 # Need Pint 0.15 for __dask_tokenize__ tests for Quantity wrapped Dask Arrays
 has_pint_0_15, requires_pint_0_15 = _importorskip("pint", minversion="0.15")
+has_numexpr, requires_numexpr = _importorskip("numexpr")

 # some special cases
 has_scipy_or_netCDF4 = has_scipy or has_netCDF4
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index 259e91083a7..c38c3656eaf 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -7,6 +7,7 @@
 import numpy as np
 import pandas as pd
 import pytest
+from pandas.core.computation.ops import UndefinedVariableError
 from pandas.tseries.frequencies import to_offset

 import xarray as xr
@@ -39,6 +40,7 @@
     requires_dask,
     requires_iris,
     requires_numbagg,
+    requires_numexpr,
     requires_scipy,
     requires_sparse,
     source_ndarray,
@@ -4620,6 +4622,74 @@ def test_pad_reflect(self, mode, reflect_type):
         assert actual.shape == (7, 4, 9)
         assert_identical(actual, expected)

+    @pytest.mark.parametrize("parser", ["pandas", "python"])
+    @pytest.mark.parametrize(
+        "engine", ["python", None, pytest.param("numexpr", marks=[requires_numexpr])]
+    )
+    @pytest.mark.parametrize(
+        "backend", ["numpy", pytest.param("dask", marks=[requires_dask])]
+    )
+    def test_query(self, backend, engine, parser):
+        """Test querying a dataset."""
+
+        # setup test data
+        np.random.seed(42)
+        a = np.arange(0, 10, 1)
+        b = np.random.randint(0, 100, size=10)
+        c = np.linspace(0, 1, 20)
+        d = np.random.choice(["foo", "bar", "baz"], size=30, replace=True).astype(
+            object
+        )
+        if backend == "numpy":
+            aa = DataArray(data=a, dims=["x"], name="a")
+            bb = DataArray(data=b, dims=["x"], name="b")
+            cc = DataArray(data=c, dims=["y"], name="c")
+            dd = DataArray(data=d, dims=["z"], name="d")
+
+        elif backend == "dask":
+            import dask.array as da
+
+            aa = DataArray(data=da.from_array(a, chunks=3), dims=["x"], name="a")
+            bb = DataArray(data=da.from_array(b, chunks=3), dims=["x"], name="b")
+            cc = DataArray(data=da.from_array(c, chunks=7), dims=["y"], name="c")
+            dd = DataArray(data=da.from_array(d, chunks=12), dims=["z"], name="d")
+
+        # query single dim, single variable
+        actual = aa.query(x="a > 5", engine=engine, parser=parser)
+        expect = aa.isel(x=(a > 5))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable, via dict
+        actual = aa.query(dict(x="a > 5"), engine=engine, parser=parser)
+        expect = aa.isel(dict(x=(a > 5)))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable
+        actual = bb.query(x="b > 50", engine=engine, parser=parser)
+        expect = bb.isel(x=(b > 50))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable
+        actual = cc.query(y="c < .5", engine=engine, parser=parser)
+        expect = cc.isel(y=(c < 0.5))
+        assert_identical(expect, actual)
+
+        # query single dim, single string variable
+        if parser == "pandas":
+            # N.B., this query currently only works with the pandas parser
+            # xref https://github.com/pandas-dev/pandas/issues/40436
+            actual = dd.query(z='d == "bar"', engine=engine, parser=parser)
+            expect = dd.isel(z=(d == "bar"))
+            assert_identical(expect, actual)
+
+        # test error handling
+        with pytest.raises(ValueError):
+            aa.query("a > 5")  # must be dict or kwargs
+        with pytest.raises(ValueError):
+            aa.query(x=(a > 5))  # must be query string
+        with pytest.raises(UndefinedVariableError):
+            aa.query(x="spam > 50")  # name not present
+

 class TestReduce:
     @pytest.fixture(autouse=True)
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index df7f54ed30a..52df7603034 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -8,6 +8,7 @@
 import numpy as np
 import pandas as pd
 import pytest
+from pandas.core.computation.ops import UndefinedVariableError
 from pandas.core.indexes.datetimes import DatetimeIndex
 from pandas.tseries.frequencies import to_offset

@@ -45,6 +46,7 @@
     requires_cftime,
     requires_dask,
     requires_numbagg,
+    requires_numexpr,
     requires_scipy,
     requires_sparse,
     source_ndarray,
@@ -5807,6 +5809,135 @@ def test_astype_attrs(self):
         assert not data.astype(float, keep_attrs=False).attrs
         assert not data.astype(float, keep_attrs=False).var1.attrs

+    @pytest.mark.parametrize("parser", ["pandas", "python"])
+    @pytest.mark.parametrize(
+        "engine", ["python", None, pytest.param("numexpr", marks=[requires_numexpr])]
+    )
+    @pytest.mark.parametrize(
+        "backend", ["numpy", pytest.param("dask", marks=[requires_dask])]
+    )
+    def test_query(self, backend, engine, parser):
+        """Test querying a dataset."""
+
+        # setup test data
+        np.random.seed(42)
+        a = np.arange(0, 10, 1)
+        b = np.random.randint(0, 100, size=10)
+        c = np.linspace(0, 1, 20)
+        d = np.random.choice(["foo", "bar", "baz"], size=30, replace=True).astype(
+            object
+        )
+        e = np.arange(0, 10 * 20).reshape(10, 20)
+        f = np.random.normal(0, 1, size=(10, 20, 30))
+        if backend == "numpy":
+            ds = Dataset(
+                {
+                    "a": ("x", a),
+                    "b": ("x", b),
+                    "c": ("y", c),
+                    "d": ("z", d),
+                    "e": (("x", "y"), e),
+                    "f": (("x", "y", "z"), f),
+                }
+            )
+        elif backend == "dask":
+            ds = Dataset(
+                {
+                    "a": ("x", da.from_array(a, chunks=3)),
+                    "b": ("x", da.from_array(b, chunks=3)),
+                    "c": ("y", da.from_array(c, chunks=7)),
+                    "d": ("z", da.from_array(d, chunks=12)),
+                    "e": (("x", "y"), da.from_array(e, chunks=(3, 7))),
+                    "f": (("x", "y", "z"), da.from_array(f, chunks=(3, 7, 12))),
+                }
+            )
+
+        # query single dim, single variable
+        actual = ds.query(x="a > 5", engine=engine, parser=parser)
+        expect = ds.isel(x=(a > 5))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable, via dict
+        actual = ds.query(dict(x="a > 5"), engine=engine, parser=parser)
+        expect = ds.isel(dict(x=(a > 5)))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable
+        actual = ds.query(x="b > 50", engine=engine, parser=parser)
+        expect = ds.isel(x=(b > 50))
+        assert_identical(expect, actual)
+
+        # query single dim, single variable
+        actual = ds.query(y="c < .5", engine=engine, parser=parser)
+        expect = ds.isel(y=(c < 0.5))
+        assert_identical(expect, actual)
+
+        # query single dim, single string variable
+        if parser == "pandas":
+            # N.B., this query currently only works with the pandas parser
+            # xref https://github.com/pandas-dev/pandas/issues/40436
+            actual = ds.query(z='d == "bar"', engine=engine, parser=parser)
+            expect = ds.isel(z=(d == "bar"))
+            assert_identical(expect, actual)
+
+        # query single dim, multiple variables
+        actual = ds.query(x="(a > 5) & (b > 50)", engine=engine, parser=parser)
+        expect = ds.isel(x=((a > 5) & (b > 50)))
+        assert_identical(expect, actual)
+
+        # query single dim, multiple variables with computation
+        actual = ds.query(x="(a * b) > 250", engine=engine, parser=parser)
+        expect = ds.isel(x=(a * b) > 250)
+        assert_identical(expect, actual)
+
+        # check pandas query syntax is supported
+        if parser == "pandas":
+            actual = ds.query(x="(a > 5) and (b > 50)", engine=engine, parser=parser)
+            expect = ds.isel(x=((a > 5) & (b > 50)))
+            assert_identical(expect, actual)
+
+        # query multiple dims via kwargs
+        actual = ds.query(x="a > 5", y="c < .5", engine=engine, parser=parser)
+        expect = ds.isel(x=(a > 5), y=(c < 0.5))
+        assert_identical(expect, actual)
+
+        # query multiple dims via kwargs
+        if parser == "pandas":
+            actual = ds.query(
+                x="a > 5", y="c < .5", z="d == 'bar'", engine=engine, parser=parser
+            )
+            expect = ds.isel(x=(a > 5), y=(c < 0.5), z=(d == "bar"))
+            assert_identical(expect, actual)
+
+        # query multiple dims via dict
+        actual = ds.query(dict(x="a > 5", y="c < .5"), engine=engine, parser=parser)
+        expect = ds.isel(dict(x=(a > 5), y=(c < 0.5)))
+        assert_identical(expect, actual)
+
+        # query multiple dims via dict
+        if parser == "pandas":
+            actual = ds.query(
+                dict(x="a > 5", y="c < .5", z="d == 'bar'"),
+                engine=engine,
+                parser=parser,
+            )
+            expect = ds.isel(dict(x=(a > 5), y=(c < 0.5), z=(d == "bar")))
+            assert_identical(expect, actual)
+
+        # test error handling
+        with pytest.raises(ValueError):
+            ds.query("a > 5")  # must be dict or kwargs
+        with pytest.raises(ValueError):
+            ds.query(x=(a > 5))  # must be query string
+        with pytest.raises(IndexError):
+            ds.query(y="a > 5")  # wrong length dimension
+        with pytest.raises(IndexError):
+            ds.query(x="c < .5")  # wrong length dimension
+        with pytest.raises(IndexError):
+            ds.query(x="e > 100")  # wrong number of dimensions
+        with pytest.raises(UndefinedVariableError):
+            ds.query(x="spam > 50")  # name not present
+

 # Py.test tests

diff --git a/doc/api.rst b/doc/api.rst
index 7e13c2fe..4af9bb01 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -138,6 +138,7 @@ Indexing
    Dataset.set_index
    Dataset.reset_index
    Dataset.reorder_levels
+   Dataset.query

 Missing value handling
 ----------------------
@@ -321,6 +322,7 @@ Indexing
    DataArray.set_index
    DataArray.reset_index
    DataArray.reorder_levels
+   DataArray.query

 Missing value handling
 ----------------------

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index e0c11fef..49589db1 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,6 +22,10 @@ v0.17.1 (unreleased)

 New Features
 ~~~~~~~~~~~~
+
+- Add :py:meth:`Dataset.query` and :py:meth:`DataArray.query` which enable indexing
+  of datasets and data arrays by evaluating query expressions against the values of the
+  data variables.
 - Allow passing ``combine_attrs`` to :py:meth:`Dataset.merge` (:pull:`4895`).
   By `Justus Magin <https://github.com/keewis>`_.
 - Support for `dask.graph_manipulation

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'parser'}, {'type': 'field', 'name': 'parser'}, {'type': 'field', 'name': 'engine'}]


diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 403227b1b6b..e22805b0342 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -82,6 +82,13 @@ New Features
 - Disable the `cfgrib` backend if the `eccodes` library is not installed (:pull:`5083`). By `Baudouin Raoult <https://github.com/b8raoult>`_.
 - Added :py:meth:`DataArray.curvefit` and :py:meth:`Dataset.curvefit` for general curve fitting applications. (:issue:`4300`, :pull:`4849`)
   By `Sam Levang <https://github.com/slevang>`_.
+- Add options to control expand/collapse of sections in display of Dataset and
+  DataArray. The function :py:func:`set_options` now takes keyword aguments
+  ``display_expand_attrs``, ``display_expand_coords``, ``display_expand_data``,
+  ``display_expand_data_vars``, all of which can be one of ``True`` to always
+  expand, ``False`` to always collapse, or ``default`` to expand unless over a
+  pre-defined limit (:pull:`5126`).
+  By `Tom White <https://github.com/tomwhite>`_.

 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/xarray/core/formatting.py b/xarray/core/formatting.py
index 13f38820e4a..31a1b777f43 100644
--- a/xarray/core/formatting.py
+++ b/xarray/core/formatting.py
@@ -11,7 +11,7 @@
 from pandas.errors import OutOfBoundsDatetime

 from .duck_array_ops import array_equiv
-from .options import OPTIONS
+from .options import OPTIONS, _get_boolean_with_default
 from .pycompat import dask_array_type, sparse_array_type
 from .utils import is_duck_array

@@ -371,7 +371,9 @@ def _calculate_col_width(col_items):
     return col_width

-def _mapping_repr(mapping, title, summarizer, col_width=None, max_rows=None):
+def _mapping_repr(
+    mapping, title, summarizer, expand_option_name, col_width=None, max_rows=None
+):
     if col_width is None:
         col_width = _calculate_col_width(mapping)
     if max_rows is None:
@@ -379,7 +381,9 @@ def _mapping_repr(mapping, title, summarizer, col_width=None, max_rows=None):
     summary = [f"{title}:"]
     if mapping:
         len_mapping = len(mapping)
-        if len_mapping > max_rows:
+        if not _get_boolean_with_default(expand_option_name, default=True):
+            summary = [f"{summary[0]} ({len_mapping})"]
+        elif len_mapping > max_rows:
             summary = [f"{summary[0]} ({max_rows}/{len_mapping})"]
             first_rows = max_rows // 2 + max_rows % 2
             items = list(mapping.items())
@@ -396,12 +400,18 @@ def _mapping_repr(mapping, title, summarizer, col_width=None, max_rows=None):

 data_vars_repr = functools.partial(
-    _mapping_repr, title="Data variables", summarizer=summarize_datavar
+    _mapping_repr,
+    title="Data variables",
+    summarizer=summarize_datavar,
+    expand_option_name="display_expand_data_vars",
 )

 attrs_repr = functools.partial(
-    _mapping_repr, title="Attributes", summarizer=summarize_attr
+    _mapping_repr,
+    title="Attributes",
+    summarizer=summarize_attr,
+    expand_option_name="display_expand_attrs",
 )

@@ -409,7 +419,11 @@ def coords_repr(coords, col_width=None):
     if col_width is None:
         col_width = _calculate_col_width(_get_col_items(coords))
     return _mapping_repr(
-        coords, title="Coordinates", summarizer=summarize_coord, col_width=col_width
+        coords,
+        title="Coordinates",
+        summarizer=summarize_coord,
+        expand_option_name="display_expand_coords",
+        col_width=col_width,
     )

@@ -493,9 +507,14 @@ def array_repr(arr):
     else:
         name_str = ""

+    if _get_boolean_with_default("display_expand_data", default=True):
+        data_repr = short_data_repr(arr)
+    else:
+        data_repr = inline_variable_array_repr(arr, OPTIONS["display_width"])
+
     summary = [
         "<xarray.{} {}({})>".format(type(arr).__name__, name_str, dim_summary(arr)),
-        short_data_repr(arr),
+        data_repr,
     ]

     if hasattr(arr, "coords"):
diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py
index 3392aef8da3..9730a0a1745 100644
--- a/xarray/core/formatting_html.py
+++ b/xarray/core/formatting_html.py
@@ -6,6 +6,7 @@
 import pkg_resources

 from .formatting import inline_variable_array_repr, short_data_repr
+from .options import _get_boolean_with_default

 STATIC_FILES = ("static/html/icons-svg-inline.html", "static/css/style.css")

@@ -164,9 +165,14 @@ def collapsible_section(
     )

-def _mapping_section(mapping, name, details_func, max_items_collapse, enabled=True):
+def _mapping_section(
+    mapping, name, details_func, max_items_collapse, expand_option_name, enabled=True
+):
     n_items = len(mapping)
-    collapsed = n_items >= max_items_collapse
+    expanded = _get_boolean_with_default(
+        expand_option_name, n_items < max_items_collapse
+    )
+    collapsed = not expanded

     return collapsible_section(
         name,
@@ -188,7 +194,11 @@ def dim_section(obj):
 def array_section(obj):
     # "unique" id to expand/collapse the section
     data_id = "section-" + str(uuid.uuid4())
-    collapsed = "checked"
+    collapsed = (
+        "checked"
+        if _get_boolean_with_default("display_expand_data", default=True)
+        else ""
+    )
     variable = getattr(obj, "variable", obj)
     preview = escape(inline_variable_array_repr(variable, max_width=70))
     data_repr = short_data_repr_html(obj)
@@ -209,6 +219,7 @@ def array_section(obj):
     name="Coordinates",
     details_func=summarize_coords,
     max_items_collapse=25,
+    expand_option_name="display_expand_coords",
 )

@@ -217,6 +228,7 @@ def array_section(obj):
     name="Data variables",
     details_func=summarize_vars,
     max_items_collapse=15,
+    expand_option_name="display_expand_data_vars",
 )

@@ -225,6 +237,7 @@ def array_section(obj):
     name="Attributes",
     details_func=summarize_attrs,
     max_items_collapse=10,
+    expand_option_name="display_expand_attrs",
 )

diff --git a/xarray/core/options.py b/xarray/core/options.py
index 129698903c4..d53c9d5d7d9 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -6,6 +6,10 @@
 DISPLAY_MAX_ROWS = "display_max_rows"
 DISPLAY_STYLE = "display_style"
 DISPLAY_WIDTH = "display_width"
+DISPLAY_EXPAND_ATTRS = "display_expand_attrs"
+DISPLAY_EXPAND_COORDS = "display_expand_coords"
+DISPLAY_EXPAND_DATA_VARS = "display_expand_data_vars"
+DISPLAY_EXPAND_DATA = "display_expand_data"
 ENABLE_CFTIMEINDEX = "enable_cftimeindex"
 FILE_CACHE_MAXSIZE = "file_cache_maxsize"
 KEEP_ATTRS = "keep_attrs"
@@ -19,6 +23,10 @@
     DISPLAY_MAX_ROWS: 12,
     DISPLAY_STYLE: "html",
     DISPLAY_WIDTH: 80,
+    DISPLAY_EXPAND_ATTRS: "default",
+    DISPLAY_EXPAND_COORDS: "default",
+    DISPLAY_EXPAND_DATA_VARS: "default",
+    DISPLAY_EXPAND_DATA: "default",
     ENABLE_CFTIMEINDEX: True,
     FILE_CACHE_MAXSIZE: 128,
     KEEP_ATTRS: "default",
@@ -38,6 +46,10 @@ def _positive_integer(value):
     DISPLAY_MAX_ROWS: _positive_integer,
     DISPLAY_STYLE: _DISPLAY_OPTIONS.__contains__,
     DISPLAY_WIDTH: _positive_integer,
+    DISPLAY_EXPAND_ATTRS: lambda choice: choice in [True, False, "default"],
+    DISPLAY_EXPAND_COORDS: lambda choice: choice in [True, False, "default"],
+    DISPLAY_EXPAND_DATA_VARS: lambda choice: choice in [True, False, "default"],
+    DISPLAY_EXPAND_DATA: lambda choice: choice in [True, False, "default"],
     ENABLE_CFTIMEINDEX: lambda value: isinstance(value, bool),
     FILE_CACHE_MAXSIZE: _positive_integer,
     KEEP_ATTRS: lambda choice: choice in [True, False, "default"],
@@ -65,8 +77,8 @@ def _warn_on_setting_enable_cftimeindex(enable_cftimeindex):
 }

-def _get_keep_attrs(default):
-    global_choice = OPTIONS["keep_attrs"]
+def _get_boolean_with_default(option, default):
+    global_choice = OPTIONS[option]

     if global_choice == "default":
         return default
@@ -74,10 +86,14 @@ def _get_keep_attrs(default):
         return global_choice
     else:
         raise ValueError(
-            "The global option keep_attrs must be one of True, False or 'default'."
+            f"The global option {option} must be one of True, False or 'default'."
         )

+def _get_keep_attrs(default):
+    return _get_boolean_with_default("keep_attrs", default)
+
+
 class set_options:
     """Set options for xarray in a controlled context.

@@ -108,6 +124,22 @@ class set_options:
       Default: ``'default'``.
     - ``display_style``: display style to use in jupyter for xarray objects.
       Default: ``'text'``. Other options are ``'html'``.
+    - ``display_expand_attrs``: whether to expand the attributes section for
+      display of ``DataArray`` or ``Dataset`` objects. Can be ``True`` to always
+      expand, ``False`` to always collapse, or ``default`` to expand unless over
+      a pre-defined limit. Default: ``default``.
+    - ``display_expand_coords``: whether to expand the coordinates section for
+      display of ``DataArray`` or ``Dataset`` objects. Can be ``True`` to always
+      expand, ``False`` to always collapse, or ``default`` to expand unless over
+      a pre-defined limit. Default: ``default``.
+    - ``display_expand_data``: whether to expand the data section for display
+      of ``DataArray`` objects. Can be ``True`` to always expand, ``False`` to
+      always collapse, or ``default`` to expand unless over a pre-defined limit.
+      Default: ``default``.
+    - ``display_expand_data_vars``: whether to expand the data variables section
+      for display of ``Dataset`` objects. Can be ``True`` to always
+      expand, ``False`` to always collapse, or ``default`` to expand unless over
+      a pre-defined limit. Default: ``default``.

     You can use ``set_options`` either as a context manager:

diff --git a/xarray/tests/test_formatting.py b/xarray/tests/test_formatting.py
index 1098587dca7..1e2a968debe 100644
--- a/xarray/tests/test_formatting.py
+++ b/xarray/tests/test_formatting.py
@@ -391,6 +391,17 @@ def test_array_repr(self):

         assert actual == expected

+        with xr.set_options(display_expand_data=False):
+            actual = formatting.array_repr(ds[(1, 2)])
+            expected = dedent(
+                """\
+            <xarray.DataArray (1, 2) (test: 1)>
+            0
+            Dimensions without coordinates: test"""
+            )
+
+            assert actual == expected
+

 def test_inline_variable_array_repr_custom_repr():
     class CustomArray:
@@ -492,3 +503,19 @@ def test__mapping_repr(display_max_rows, n_vars, n_attr):
         len_summary = len(summary)
         data_vars_print_size = min(display_max_rows, len_summary)
         assert len_summary == data_vars_print_size
+
+    with xr.set_options(
+        display_expand_coords=False,
+        display_expand_data_vars=False,
+        display_expand_attrs=False,
+    ):
+        actual = formatting.dataset_repr(ds)
+        expected = dedent(
+            f"""\
+            <xarray.Dataset>
+            Dimensions:      (time: 2)
+            Coordinates: (1)
+            Data variables: ({n_vars})
+            Attributes: ({n_attr})"""
+        )
+        assert actual == expected
diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py
index 9a210ad6fa3..4cf4509c7eb 100644
--- a/xarray/tests/test_formatting_html.py
+++ b/xarray/tests/test_formatting_html.py
@@ -115,6 +115,17 @@ def test_repr_of_dataarray(dataarray):
         formatted.count("class='xr-section-summary-in' type='checkbox' disabled >") == 2
     )

+    with xr.set_options(display_expand_data=False):
+        formatted = fh.array_repr(dataarray)
+        assert "dim_0" in formatted
+        # has an expanded data section
+        assert formatted.count("class='xr-array-in' type='checkbox' checked>") == 0
+        # coords and attrs don't have an items so they'll be be disabled and collapsed
+        assert (
+            formatted.count("class='xr-section-summary-in' type='checkbox' disabled >")
+            == 2
+        )
+

 def test_summary_of_multiindex_coord(multiindex):
     idx = multiindex.x.variable.to_index_variable()
@@ -138,6 +149,20 @@ def test_repr_of_dataset(dataset):
     assert "<U4" in formatted or ">U4" in formatted
     assert "<IA>" in formatted

+    with xr.set_options(
+        display_expand_coords=False,
+        display_expand_data_vars=False,
+        display_expand_attrs=False,
+    ):
+        formatted = fh.dataset_repr(dataset)
+        # coords, attrs, and data_vars are collapsed
+        assert (
+            formatted.count("class='xr-section-summary-in' type='checkbox'  checked>")
+            == 0
+        )
+        assert "<U4" in formatted or ">U4" in formatted
+        assert "<IA>" in formatted
+

 def test_repr_text_fallback(dataset):
     formatted = fh.dataset_repr(dataset)

diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 403227b1..a7c456c6 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -82,6 +82,12 @@ New Features
 - Disable the `cfgrib` backend if the `eccodes` library is not installed (:pull:`5083`). By `Baudouin Raoult <https://github.com/b8raoult>`_.
 - Added :py:meth:`DataArray.curvefit` and :py:meth:`Dataset.curvefit` for general curve fitting applications. (:issue:`4300`, :pull:`4849`)
   By `Sam Levang <https://github.com/slevang>`_.
+- Add options to control expand/collapse of sections in display of Dataset and
+  DataArray. The function :py:func:`set_options` now takes keyword aguments
+  ``display_expand_attrs``, ``display_expand_coords``, ``display_expand_data``,
+  ``display_expand_data_vars``, all of which can be one of ``True`` to always
+  expand, ``False`` to always collapse, or ``default`` to expand unless over a
+  pre-defined limit.

 Breaking changes
 ~~~~~~~~~~~~~~~~