diff --git a/CHANGES.md b/CHANGES.md
index df74c2d..ffa8f92 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,21 @@
 
 ### Enhancements
 
+* The configuration setting  `attrs` can now be used to define dynamically 
+  computed dataset attributes using the syntax `{{ expression }}`. [#60]
+  
+  Example:
+  ```yaml
+  permit_eval: true
+  attrs:
+    title: HROC Ocean Colour Monthly Composite
+    time_coverage_start: {{ lower_bound(ds.time) }}
+    time_coverage_end: {{ upper_bound(ds.time) }}
+  ```
+
+* Introduced new configuration setting `attrs_update_mode` that controls 
+  how dataset attributes are updated. [#59]
+
 * Simplified logging to console. You can now set configuration setting `logging`
   to a log level which will implicitly enable console logging with given log
   level. [#64]
@@ -9,8 +24,12 @@
 * Added a section in the notebook `examples/zappend-demo.ipynb`
   that demonstrates transaction rollbacks.
 
+
 * Added CLI option `--traceback`. [#57]
 
+* Added a section in the notebook `examples/zappend-demo.ipynb`
+  that demonstrates transaction rollbacks.
+
 
 ## Version 0.4.1 (from 2024-02-13)
 
diff --git a/docs/config.md b/docs/config.md
index 3451b61..a831d23 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -143,6 +143,36 @@ Variable metadata.
     Type _object_.
     Arbitrary variable metadata attributes.
 
+## `attrs`
+
+Type _object_.
+Arbitrary dataset attributes. If `permit_eval` is set to `true`, string values may include Python expressions enclosed in `{{` and `}}` to dynamically compute attribute values; in the expression, the current dataset  is named `ds`. Refer to the user guide for more information.
+
+## `attrs_update_mode`
+
+The mode used update target attributes from slice attributes. Independently of this setting, extra attributes configured by the `attrs` setting will finally be used to update the resulting target attributes.
+Must be one of the following:
+
+  * Use attributes from first slice dataset and keep them.
+    Its value is `"keep"`.
+
+  * Replace existing attributes by attributes of last slice dataset.
+    Its value is `"replace"`.
+
+  * Update existing attributes by attributes of last slice dataset.
+    Its value is `"update"`.
+
+  * Ignore attributes from slice datasets.
+    Its value is `"ignore"`.
+
+Defaults to `"keep"`.
+
+## `permit_eval`
+
+Type _boolean_.
+Allow for dynamically computed values in dataset attributes `attrs` using the syntax `{{ expression }}`.  Executing arbitrary Python expressions is a security risk, therefore this must be explicitly enabled. Refer to the user guide for more information.
+Defaults to `false`.
+
 ## `target_dir`
 
 Type _string_.
diff --git a/docs/guide.md b/docs/guide.md
index 3aab6ec..f93cbdf 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -84,7 +84,9 @@ This remainder of this guide explains the how to use the various `zappend`
     variables. A variable comprises the actual data array as well as metadata describing 
     the data dimensions, units, and encoding, such as chunking and compression.
 
-## Dataset Outline
+## Dataset Metadata
+
+### Outline
 
 If no further configuration is supplied, then the target dataset's outline and data 
 encoding is fully prescribed by the first slice dataset provided. By default, the
@@ -152,6 +154,85 @@ Often, it is easier to specify which variables should be excluded:
     "excluded_variables": ["GridCellId"]
 }
 ```
+### Attributes
+
+The target dataset should exploit information about itself using global 
+metadata attributes.
+There are three choices to update the global attributes of the target 
+dataset from slices. The configuration setting `attrs_update_mode` 
+controls how this is done:
+
+* `"keep"` - use attributes from first slice dataset and keep them (default);
+* `"replace"` - replace existing attributes by attributes of last slice dataset;
+* `"update"` - update existing attributes by attributes of last slice dataset;
+* `"ignore"` - ignore attributes from slice datasets.
+
+Extra attributes can be added using the optional configuration setting `attrs`:
+
+```json
+{
+    "attrs_update_mode": "keep",
+    "attrs": {
+        "Conventions": "CF-1.10",
+        "title": "SMOS Level 2C Soil Moisture 2-Days Composite"
+    }
+}
+```
+
+Independently of the `attrs_update_mode` setting, extra attributes configured 
+by the `attrs` setting will always be used to update the resulting target 
+attributes. 
+
+Attribute values in the `attrs` setting may also be computed dynamically using 
+the syntax `{{ expression }}`, where `expression` is an arbitrary Python 
+expression. For this to work, the setting `permit_eval` must be explicitly 
+set for security reasons:
+
+```json
+{
+    "permit_eval": true,
+    "attrs_update_mode": "keep",
+    "attrs": {
+        "time_coverage_start": "{{ ds.time[0] }}", 
+        "time_coverage_end": "{{ ds.time[-1] }}"
+    }
+}
+```
+
+Currently, the only variable accessible from expressions is `ds` which is 
+a reference to the current state of the target dataset after the last slice 
+append. It is of type 
+[xarray.Dataset](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html).
+
+!!! danger "Evil eval()"
+    The expressions in `{{ expression }}` are evaluated using the Python 
+    [eval() function](https://docs.python.org/3/library/functions.html#eval).
+    This can pose a threat to your application and environment. 
+    Although `zappend` does not allow you to directly access Python built-in 
+    functions via expressions, it should be used judiciously and with extreme 
+    caution if used as part of a web service where configuration is injected 
+    from the outside of your network.
+
+The following utility functions can be used as well and are handy if you need 
+to store the upper and lower bounds of coordinates as attribute values:
+
+* `lower_bound(array, ref: "lower"|"upper"|"center" = "lower")`:
+  Return the lower bound of a one-dimensional (coordinate) array `array`.
+* `upper_bound(array, ref: "lower"|"upper"|"center" = "lower")`:
+  Return the upper bound of a one-dimensional (coordinate) array `array`.
+ 
+The `ref` value specifies the reference within an array element that is used 
+as a basis for the boundary computation. E.g., if coordinate labels refer to 
+array element centers, pass `ref="center"`.
+    
+```json
+{
+    "attrs": {
+        "time_coverage_start": "{{ lower_bound(ds.time, 'center') }}", 
+        "time_coverage_end": "{{ upper_bound(ds.time, 'center') }}"
+    }
+}
+```
 
 ## Variable Metadata 
 
diff --git a/tests/config/__init__.py b/tests/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/config/test_attrs.py b/tests/config/test_attrs.py
new file mode 100644
index 0000000..dcff06b
--- /dev/null
+++ b/tests/config/test_attrs.py
@@ -0,0 +1,353 @@
+# Copyright © 2024 Norman Fomferra
+# Permissions are hereby granted under the terms of the MIT License:
+# https://opensource.org/licenses/MIT.
+
+import datetime
+import json
+import unittest
+
+import numpy as np
+import xarray as xr
+import pytest
+
+from zappend.config.attrs import ConfigAttrsUserFunctions
+from zappend.config.attrs import eval_dyn_config_attrs
+from zappend.config.attrs import eval_expr
+from zappend.config.attrs import get_dyn_config_attrs_env
+from zappend.config.attrs import has_dyn_config_attrs
+from ..helpers import make_test_dataset
+
+
+class EvalDynConfigAttrsTest(unittest.TestCase):
+    def setUp(self):
+        ds = make_test_dataset()
+        ds.attrs["title"] = "Ocean Colour"
+        self.env = get_dyn_config_attrs_env(ds, N=10)
+
+    def test_zero(self):
+        self.assertEqual(
+            {"value": "Chlorophyll A"},
+            eval_dyn_config_attrs({"value": "Chlorophyll A"}, self.env),
+        )
+        self.assertEqual(
+            {"value": 11},
+            eval_dyn_config_attrs({"value": 11}, self.env),
+        )
+
+    def test_one(self):
+        self.assertEqual(
+            {"value": "Ocean Colour"},
+            eval_dyn_config_attrs({"value": "{{ ds.attrs['title'] }}"}, self.env),
+        )
+        self.assertEqual(
+            {"value": 10},
+            eval_dyn_config_attrs({"value": "{{ N }}"}, self.env),
+        )
+        self.assertEqual(
+            {"value": " 10 "},
+            eval_dyn_config_attrs({"value": " {{ N }} "}, self.env),
+        )
+
+    def test_two(self):
+        self.assertEqual(
+            {"value": "Ocean Colour10"},
+            eval_dyn_config_attrs({"value": "{{ds.attrs['title']}}{{N}}"}, self.env),
+        )
+        self.assertEqual(
+            {"value": "Ocean Colour / 10"},
+            eval_dyn_config_attrs({"value": "{{ds.attrs['title']}} / {{N}}"}, self.env),
+        )
+        self.assertEqual(
+            {"value": "1010"},
+            eval_dyn_config_attrs({"value": "{{ N }}{{ N }}"}, self.env),
+        )
+
+    def test_x_min_max(self):
+        attrs = eval_dyn_config_attrs(
+            {"x_min": "{{ ds.x[0] }}", "x_max": "{{ ds.x[-1] }}"}, self.env
+        )
+        self.assertEqual(
+            {"x_min": 0.005, "x_max": 0.995},
+            attrs,
+        )
+        self.assertEqual(attrs, json.loads(json.dumps(attrs)))
+
+    def test_x_min_max_center(self):
+        attrs = eval_dyn_config_attrs(
+            {
+                "x_min": "{{ ds.x[0] - (ds.x[1]-ds.x[0])/2 }}",
+                "x_max": "{{ ds.x[-1] + (ds.x[1]-ds.x[0])/2 }}",
+            },
+            self.env,
+        )
+        self.assertAlmostEqual(0.0, attrs.get("x_min"))
+        self.assertAlmostEqual(1.0, attrs.get("x_max"))
+        self.assertEqual(attrs, json.loads(json.dumps(attrs)))
+
+    def test_x_bounds_center(self):
+        attrs = eval_dyn_config_attrs(
+            {
+                "x_min": "{{ lower_bound(ds.x, ref='center') }}",
+                "x_max": "{{ upper_bound(ds.x, ref='center') }}",
+            },
+            self.env,
+        )
+        self.assertAlmostEqual(0.0, attrs.get("x_min"))
+        self.assertAlmostEqual(1.0, attrs.get("x_max"))
+        self.assertEqual(attrs, json.loads(json.dumps(attrs)))
+
+    def test_time_min_max(self):
+        attrs = eval_dyn_config_attrs(
+            {"time_min": "{{ ds.time[0] }}", "time_max": "{{ ds.time[-1] }}"}, self.env
+        )
+        self.assertEqual(
+            {"time_min": "2024-01-01T00:00:00", "time_max": "2024-01-03T00:00:00"},
+            attrs,
+        )
+        self.assertEqual(attrs, json.loads(json.dumps(attrs)))
+
+    def test_time_bounds(self):
+        attrs = eval_dyn_config_attrs(
+            {
+                "time_min": "{{ lower_bound(ds.time) }}",
+                "time_max": "{{ upper_bound(ds.time) }}",
+            },
+            self.env,
+        )
+        self.assertEqual(
+            {"time_min": "2024-01-01T00:00:00", "time_max": "2024-01-04T00:00:00"},
+            attrs,
+        )
+        self.assertEqual(attrs, json.loads(json.dumps(attrs)))
+
+
+class HasDynConfigAttrsTest(unittest.TestCase):
+    def test_no(self):
+        self.assertFalse(
+            has_dyn_config_attrs({"value": 3}),
+        )
+        self.assertFalse(
+            has_dyn_config_attrs({"value": "Chlorophyll A"}),
+        )
+        self.assertFalse(
+            has_dyn_config_attrs({"value": "Chlorophyll {{A"}),
+        )
+        self.assertFalse(
+            has_dyn_config_attrs({"value": "Chlorophyll A}}"}),
+        )
+
+    def test_yes(self):
+        self.assertTrue(
+            has_dyn_config_attrs({"value": "{{}}"}),
+        )
+        self.assertTrue(
+            has_dyn_config_attrs({"value": "{{A}}"}),
+        )
+        self.assertTrue(
+            has_dyn_config_attrs({"value": "{{'Number'}} {{2}}"}),
+        )
+
+
+class EvalExprTest(unittest.TestCase):
+    def test_scalar_result(self):
+        # scalars
+        self.assertEqual(None, eval_expr("None", {}))
+        self.assertEqual(True, eval_expr("True", {}))
+        self.assertEqual(13, eval_expr("13", {}))
+        self.assertEqual("nan", eval_expr("float('NaN')", {}))
+        self.assertEqual(0.5, eval_expr("0.5", {}))
+        self.assertEqual("ABC", eval_expr("'ABC'", {}))
+        time = datetime.date.fromisoformat("2024-01-02")
+        self.assertEqual(
+            "2024-01-02",
+            eval_expr("time", dict(time=time)),
+        )
+        time = datetime.datetime.fromisoformat("2024-01-02T10:20:30")
+        self.assertEqual(
+            "2024-01-02T10:20:30",
+            eval_expr("time", dict(time=time)),
+        )
+        with pytest.raises(
+            ValueError, match="cannot serialize value of type <class 'object'>"
+        ):
+            eval_expr("obj", dict(obj=object()))
+
+    def test_dict_result(self):
+        self.assertEqual({}, eval_expr("d", dict(d={})))
+        self.assertEqual(
+            {
+                "b": True,
+                "i": 13,
+                "t": [1, "B", {}],
+                "f": 13.2,
+                "d": "2020-05-04",
+                "np_a": [0.1, 0.2],
+                "xr_a": [0.3, 0.4],
+            },
+            eval_expr(
+                "d",
+                dict(
+                    d={
+                        "b": True,
+                        "i": 13,
+                        "t": (1, "B", {}),
+                        "f": 13.2,
+                        "d": datetime.date.fromisoformat("2020-05-04"),
+                        "np_a": np.array([0.1, 0.2]),
+                        "xr_a": xr.DataArray(np.array([0.3, 0.4])),
+                    }
+                ),
+            ),
+        )
+
+    def test_array_1d_result(self):
+        # arrays
+        self.assert_array_ok([True, False])
+        self.assert_array_ok([3, 4, 5])
+        self.assert_array_ok([11.05, 11.15, 11.25])
+        self.assert_array_ok([11.05, "nan", 11.25], dtype="float64")
+        self.assert_array_ok(["A", "B"])
+        self.assert_array_ok(["2024-01-02T10:20:30"], dtype="datetime64[s]")
+        with pytest.raises(
+            ValueError,
+            match=(
+                "cannot serialize 0-d array"
+                " of type <class 'numpy.ndarray'>, dtype=dtype\\('O'\\)"
+            ),
+        ):
+            eval_expr("a", dict(a=xr.DataArray([object(), object()])))
+
+    def test_array_2d_result(self):
+        self.assert_array_ok([[3, 4], [5, 6]])
+
+    def assert_array_ok(self, a: list, dtype=None):
+        # Test list
+        self.assertEqual(
+            a,
+            eval_expr("a", dict(a=a)),
+        )
+        self.assertEqual(
+            a[0],
+            eval_expr("a[0]", dict(a=a)),
+        )
+
+        # Test numpy.ndarray
+        np_a = np.array(a, dtype=dtype) if dtype is not None else np.array(a)
+        self.assertEqual(
+            a,
+            eval_expr("a", dict(a=np_a)),
+        )
+        self.assertEqual(
+            a[0],
+            eval_expr("a[0]", dict(a=np_a)),
+        )
+
+        # Test xarray-DataArray
+        xr_a = xr.DataArray(np_a)
+        self.assertEqual(
+            a,
+            eval_expr("a", dict(a=xr_a)),
+        )
+        self.assertEqual(
+            a[0],
+            eval_expr("a[0]", dict(a=xr_a)),
+        )
+
+
+class GetDynConfigAttrsEnvTest(unittest.TestCase):
+    def test_env(self):
+        ds = make_test_dataset()
+        env = get_dyn_config_attrs_env(ds)
+        self.assertIsInstance(env, dict)
+        self.assertEqual({"ds", "lower_bound", "upper_bound"}, set(env.keys()))
+        self.assertIs(ds, env["ds"])
+        self.assertTrue(callable(env["lower_bound"]))
+        self.assertTrue(callable(env["upper_bound"]))
+
+
+class ConfigAttrsUserFunctionsTest(unittest.TestCase):
+    def test_lower_bounds_numerical_ok(self):
+        self._assert_lower_bound_ok(np.array([0, 1, 2]), 0, -0.5, -1)
+        self._assert_lower_bound_ok(np.array([2, 1, 0]), 0, -0.5, -1)
+        self._assert_lower_bound_ok(np.array([0.5, 1.5, 2.5]), 0.5, 0.0, -0.5)
+
+    def test_lower_bounds_datetime_ok(self):
+        self._assert_lower_bound_ok(
+            np.array(["2024-01-01", "2024-01-02", "2024-01-03"], dtype="datetime64[h]"),
+            np.datetime64("2024-01-01T00", "h"),
+            np.datetime64("2023-12-31T12", "h"),
+            np.datetime64("2023-12-31T00", "h"),
+        )
+        self._assert_lower_bound_ok(
+            np.array(
+                ["2024-01-01 12:00:00", "2024-01-02 12:00:00", "2024-01-03 12:00:00"],
+                dtype="datetime64[h]",
+            ),
+            np.datetime64("2024-01-01T12", "h"),
+            np.datetime64("2024-01-01T00", "h"),
+            np.datetime64("2023-12-31T12", "h"),
+        )
+
+    def test_upper_bounds_numerical_ok(self):
+        self._assert_upper_bound_ok(np.array([0, 1, 2]), 3, 2.5, 2)
+        self._assert_upper_bound_ok(np.array([2, 1, 0]), 3, 2.5, 2)
+        self._assert_upper_bound_ok(np.array([2.5, 1.5, 0.5]), 3.5, 3.0, 2.5)
+
+    def test_upper_bounds_datetime_ok(self):
+        self._assert_upper_bound_ok(
+            np.array(["2024-01-01", "2024-01-02", "2024-01-03"], dtype="datetime64[h]"),
+            np.datetime64("2024-01-04T00", "h"),
+            np.datetime64("2024-01-03T12", "h"),
+            np.datetime64("2024-01-03T00", "h"),
+        )
+        self._assert_upper_bound_ok(
+            np.array(
+                ["2024-01-01 12:00:00", "2024-01-02 12:00:00", "2024-01-03 12:00:00"],
+                dtype="datetime64[h]",
+            ),
+            np.datetime64("2024-01-04T12", "h"),
+            np.datetime64("2024-01-04T00", "h"),
+            np.datetime64("2024-01-03T12", "h"),
+        )
+
+    def _assert_lower_bound_ok(
+        self, array, expected_lower, expected_center, expected_upper
+    ):
+        self._assert_bound_func_ok(
+            ConfigAttrsUserFunctions.lower_bound,
+            array,
+            expected_lower,
+            expected_center,
+            expected_upper,
+        )
+
+    def _assert_upper_bound_ok(
+        self, array, expected_lower, expected_center, expected_upper
+    ):
+        self._assert_bound_func_ok(
+            ConfigAttrsUserFunctions.upper_bound,
+            array,
+            expected_lower,
+            expected_center,
+            expected_upper,
+        )
+
+    def _assert_bound_func_ok(
+        self, f, array, expected_lower, expected_center, expected_upper
+    ):
+        self.assertEqual(expected_lower, f(array), "default")
+        self.assertEqual(expected_lower, f(array, ref="lower"), "lower")
+        self.assertEqual(expected_center, f(array, ref="center"), "center")
+        self.assertEqual(expected_upper, f(array, ref="upper"), "upper")
+
+    def test_upper_lower_bounds_fail_for_wrong_shape(self):
+        self._assert_upper_lower_bounds_fail(np.array(3))
+        self._assert_upper_lower_bounds_fail(np.array([]))
+        self._assert_upper_lower_bounds_fail(np.array([[1, 2], [3, 4]]))
+
+    # noinspection PyMethodMayBeStatic
+    def _assert_upper_lower_bounds_fail(self, array):
+        with pytest.raises(ValueError):
+            ConfigAttrsUserFunctions.lower_bound(array)
+        with pytest.raises(ValueError):
+            ConfigAttrsUserFunctions.upper_bound(array)
diff --git a/tests/test_config.py b/tests/config/test_config.py
similarity index 85%
rename from tests/test_config.py
rename to tests/config/test_config.py
index de4febc..ea3c9c8 100644
--- a/tests/test_config.py
+++ b/tests/config/test_config.py
@@ -11,12 +11,11 @@
 import yaml
 
 from zappend.config import exclude_from_config
-from zappend.config import get_config_schema
 from zappend.config import merge_configs
 from zappend.config import normalize_config
 from zappend.config import validate_config
 from zappend.fsutil.fileobj import FileObj
-from .helpers import clear_memory_fs
+from ..helpers import clear_memory_fs
 
 
 class ConfigValidateTest(unittest.TestCase):
@@ -267,48 +266,3 @@ def test_exclude_from_config(self):
             self.assertEqual({"b": 2}, config)
         with exclude_from_config({"a": 1, "b": 2}, "b", "a") as config:
             self.assertEqual({}, config)
-
-
-class ConfigSchemaTest(unittest.TestCase):
-    def test_get_config_schema(self):
-        schema = get_config_schema()
-        self.assertIn("properties", schema)
-        self.assertIsInstance(schema["properties"], dict)
-        self.assertEqual(
-            {
-                "append_dim",
-                "append_step",
-                "disable_rollback",
-                "dry_run",
-                "excluded_variables",
-                "fixed_dims",
-                "included_variables",
-                "logging",
-                "persist_mem_slices",
-                "profiling",
-                "slice_engine",
-                "slice_polling",
-                "slice_source",
-                "slice_storage_options",
-                "target_storage_options",
-                "target_dir",
-                "temp_dir",
-                "temp_storage_options",
-                "variables",
-                "version",
-                "zarr_version",
-            },
-            set(schema["properties"].keys()),
-        )
-
-    def test_get_config_schema_json(self):
-        # Smoke test is sufficient here
-        text = get_config_schema(format="json")
-        self.assertIsInstance(text, str)
-        self.assertTrue(len(text) > 0)
-
-    def test_get_config_schema_md(self):
-        # Smoke test is sufficient here
-        text = get_config_schema(format="md")
-        self.assertIsInstance(text, str)
-        self.assertTrue(len(text) > 0)
diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py
new file mode 100644
index 0000000..90aff4a
--- /dev/null
+++ b/tests/config/test_schema.py
@@ -0,0 +1,55 @@
+# Copyright © 2024 Norman Fomferra
+# Permissions are hereby granted under the terms of the MIT License:
+# https://opensource.org/licenses/MIT.
+
+import unittest
+
+from zappend.config import get_config_schema
+
+
+class ConfigSchemaTest(unittest.TestCase):
+    def test_get_config_schema(self):
+        schema = get_config_schema()
+        self.assertIn("properties", schema)
+        self.assertIsInstance(schema["properties"], dict)
+        self.assertEqual(
+            {
+                "append_dim",
+                "append_step",
+                "attrs",
+                "attrs_update_mode",
+                "disable_rollback",
+                "dry_run",
+                "excluded_variables",
+                "fixed_dims",
+                "included_variables",
+                "logging",
+                "permit_eval",
+                "persist_mem_slices",
+                "profiling",
+                "slice_engine",
+                "slice_polling",
+                "slice_source",
+                "slice_storage_options",
+                "target_storage_options",
+                "target_dir",
+                "temp_dir",
+                "temp_storage_options",
+                "variables",
+                "version",
+                "zarr_version",
+            },
+            set(schema["properties"].keys()),
+        )
+
+    def test_get_config_schema_json(self):
+        # Smoke test is sufficient here
+        text = get_config_schema(format="json")
+        self.assertIsInstance(text, str)
+        self.assertTrue(len(text) > 0)
+
+    def test_get_config_schema_md(self):
+        # Smoke test is sufficient here
+        text = get_config_schema(format="md")
+        self.assertIsInstance(text, str)
+        self.assertTrue(len(text) > 0)
diff --git a/tests/helpers.py b/tests/helpers.py
index 62059aa..e635930 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -78,6 +78,8 @@ def make_test_dataset(
     location using *storage_options* and ``decode_cf=False``.
     """
     one_day = np.timedelta64(1, "D")
+    x_res = 1.0 / shape[-1]
+    y_res = 1.0 / shape[-2]
     ds = xr.Dataset(
         data_vars=dict(
             chl=xr.DataArray(
@@ -102,10 +104,14 @@ def make_test_dataset(
                 dims=dims[0],
             ),
             dims[1]: xr.DataArray(
-                np.linspace(0, 1, shape[1], dtype="float64"), dims=dims[1]
+                np.linspace(0, 1, shape[1], endpoint=False, dtype="float64")
+                + y_res / 2,
+                dims=dims[1],
             ),
             dims[2]: xr.DataArray(
-                np.linspace(0, 1, shape[2], dtype="float64"), dims=dims[2]
+                np.linspace(0, 1, shape[2], endpoint=False, dtype="float64")
+                + x_res / 2,
+                dims=dims[2],
             ),
         },
         attrs={
diff --git a/tests/test_api.py b/tests/test_api.py
index 7815688..2eff16a 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,6 +1,7 @@
 # Copyright © 2024 Norman Fomferra
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
+
 import os
 import shutil
 import unittest
@@ -284,6 +285,54 @@ def test_some_slices_with_dec_append_labels(self):
         ):
             zappend(slices, target_dir=target_dir, append_step=append_step)
 
+    def test_dynamic_attrs_with_one_slice(self):
+        target_dir = "memory://target.zarr"
+        slices = [make_test_dataset()]
+        zappend(
+            slices,
+            target_dir=target_dir,
+            permit_eval=True,
+            attrs={
+                "title": "HROC Ocean Colour Monthly Composite",
+                "time_coverage_start": "{{ ds.time[0] }}",
+                "time_coverage_end": "{{ ds.time[-1] }}",
+            },
+        )
+        ds = xr.open_zarr(target_dir)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "title": "HROC Ocean Colour Monthly Composite",
+                "time_coverage_start": np.datetime_as_string(ds.time[0], unit="s"),
+                "time_coverage_end": np.datetime_as_string(ds.time[-1], unit="s"),
+            },
+            ds.attrs,
+        )
+
+    def test_dynamic_attrs_with_some_slices(self):
+        target_dir = "memory://target.zarr"
+        slices = [make_test_dataset(index=i) for i in range(3)]
+        zappend(
+            slices,
+            target_dir=target_dir,
+            permit_eval=True,
+            attrs={
+                "title": "HROC Ocean Colour Monthly Composite",
+                "time_coverage_start": "{{ ds.time[0] }}",
+                "time_coverage_end": "{{ ds.time[-1] }}",
+            },
+        )
+        ds = xr.open_zarr(target_dir)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "title": "HROC Ocean Colour Monthly Composite",
+                "time_coverage_start": np.datetime_as_string(ds.time[0], unit="s"),
+                "time_coverage_end": np.datetime_as_string(ds.time[-1], unit="s"),
+            },
+            ds.attrs,
+        )
+
     def test_some_slices_with_profiling(self):
         target_dir = "memory://target.zarr"
         slices = [
diff --git a/tests/test_context.py b/tests/test_context.py
index 5c2f83e..e14e2fc 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -37,16 +37,16 @@ def test_with_existing_target(self):
 
     def test_append_dim(self):
         ctx = Context({"target_dir": "memory://target.zarr"})
-        self.assertEqual("time", ctx.append_dim_name)
+        self.assertEqual("time", ctx.append_dim)
         ctx = Context({"target_dir": "memory://target.zarr", "append_dim": "depth"})
-        self.assertEqual("depth", ctx.append_dim_name)
+        self.assertEqual("depth", ctx.append_dim)
 
     def test_append_step(self):
         make_test_dataset(uri="memory://target.zarr")
         ctx = Context({"target_dir": "memory://target.zarr"})
-        self.assertEqual(None, ctx.append_step_size)
+        self.assertEqual(None, ctx.append_step)
         ctx = Context({"target_dir": "memory://target.zarr", "append_step": "1D"})
-        self.assertEqual("1D", ctx.append_step_size)
+        self.assertEqual("1D", ctx.append_step)
 
     def test_last_append_label(self):
         make_test_dataset(uri="memory://target.zarr")
@@ -57,6 +57,24 @@ def test_last_append_label(self):
         ctx = Context({"target_dir": "memory://target.zarr", "append_step": "1D"})
         self.assertEqual(np.datetime64("2024-01-03"), ctx.last_append_label)
 
+    def test_attrs(self):
+        make_test_dataset(uri="memory://target.zarr")
+        ctx = Context({"target_dir": "memory://target.zarr"})
+        self.assertEqual({}, ctx.attrs)
+        self.assertEqual("keep", ctx.attrs_update_mode)
+        self.assertEqual(False, ctx.permit_eval)
+        ctx = Context(
+            {
+                "target_dir": "memory://target.zarr",
+                "attrs": {"title": "OCC 2024"},
+                "attrs_update_mode": "update",
+                "permit_eval": True,
+            }
+        )
+        self.assertEqual({"title": "OCC 2024"}, ctx.attrs)
+        self.assertEqual("update", ctx.attrs_update_mode)
+        self.assertEqual(True, ctx.permit_eval)
+
     def test_slice_polling(self):
         ctx = Context({"target_dir": "memory://target.zarr"})
         self.assertEqual((None, None), ctx.slice_polling)
diff --git a/tests/test_tailoring.py b/tests/test_tailoring.py
index 2cdb7d3..5f17b2b 100644
--- a/tests/test_tailoring.py
+++ b/tests/test_tailoring.py
@@ -8,14 +8,23 @@
 import pyproj
 import xarray as xr
 
-from zappend.metadata import DatasetMetadata
+from zappend.context import Context
 from zappend.tailoring import tailor_target_dataset
 from zappend.tailoring import tailor_slice_dataset
 
 
+def make_context(config: dict, target_ds: xr.Dataset, write: bool = False) -> Context:
+    target_dir = "memory://target.zarr"
+    if write:
+        target_ds.to_zarr(target_dir, mode="w")
+    ctx = Context({"target_dir": target_dir, **config})
+    ctx.target_metadata = ctx.get_dataset_metadata(target_ds)
+    return ctx
+
+
 class TailorTargetDatasetTest(unittest.TestCase):
-    def test_it_sets_metadata(self):
-        ds = xr.Dataset(
+    def test_it_sets_vars_metadata(self):
+        slice_ds = xr.Dataset(
             {
                 "a": xr.DataArray(
                     np.zeros((2, 3, 4)),
@@ -29,18 +38,17 @@ def test_it_sets_metadata(self):
                 ),
             }
         )
-        tailored_ds = tailor_target_dataset(
-            ds,
-            DatasetMetadata.from_dataset(
-                ds,
-                {
-                    "variables": {
-                        "a": {"encoding": {"dtype": "uint8", "fill_value": 0}},
-                        "b": {"encoding": {"dtype": "int8", "fill_value": -1}},
-                    }
+        ctx = make_context(
+            {
+                "variables": {
+                    "a": {"encoding": {"dtype": "uint8", "fill_value": 0}},
+                    "b": {"encoding": {"dtype": "int8", "fill_value": -1}},
                 },
-            ),
+            },
+            slice_ds,
         )
+
+        tailored_ds = tailor_target_dataset(ctx, slice_ds)
         self.assertIsInstance(tailored_ds, xr.Dataset)
         self.assertEqual({"a", "b"}, set(tailored_ds.variables.keys()))
 
@@ -57,49 +65,45 @@ def test_it_sets_metadata(self):
         self.assertEqual({"units": "g/m^3"}, b.attrs)
 
     def test_it_strips_vars(self):
-        ds = xr.Dataset(
+        slice_ds = xr.Dataset(
             {
                 "a": xr.DataArray(np.zeros((2, 3, 4)), dims=("time", "y", "x")),
                 "b": xr.DataArray(np.zeros((2, 3, 4)), dims=("time", "y", "x")),
             }
         )
 
-        tailored_ds = tailor_target_dataset(
-            ds, DatasetMetadata.from_dataset(ds, {"included_variables": ["b"]})
-        )
+        ctx = make_context({"included_variables": ["b"]}, slice_ds)
+        tailored_ds = tailor_target_dataset(ctx, slice_ds)
         self.assertEqual({"b"}, set(tailored_ds.variables.keys()))
 
-        tailored_ds = tailor_target_dataset(
-            ds, DatasetMetadata.from_dataset(ds, {"excluded_variables": ["b"]})
-        )
+        ctx = make_context({"excluded_variables": ["b"]}, slice_ds)
+        tailored_ds = tailor_target_dataset(ctx, slice_ds)
         self.assertEqual({"a"}, set(tailored_ds.variables.keys()))
 
     def test_it_completes_vars(self):
-        ds = xr.Dataset(
+        slice_ds = xr.Dataset(
             {
                 "a": xr.DataArray(np.zeros((2, 3, 4)), dims=("time", "y", "x")),
             }
         )
-
-        tailored_ds = tailor_target_dataset(
-            ds,
-            DatasetMetadata.from_dataset(
-                ds,
-                {
-                    "variables": {
-                        "a": {"dims": ["time", "y", "x"]},
-                        "b": {
-                            "dims": ["time", "y", "x"],
-                            "encoding": {"dtype": "int16", "fill_value": 0},
-                        },
-                        "c": {
-                            "dims": ["time", "y", "x"],
-                            "encoding": {"dtype": "uint32"},
-                        },
+        ctx = make_context(
+            {
+                "variables": {
+                    "a": {"dims": ["time", "y", "x"]},
+                    "b": {
+                        "dims": ["time", "y", "x"],
+                        "encoding": {"dtype": "int16", "fill_value": 0},
+                    },
+                    "c": {
+                        "dims": ["time", "y", "x"],
+                        "encoding": {"dtype": "uint32"},
                     },
                 },
-            ),
+            },
+            slice_ds,
         )
+
+        tailored_ds = tailor_target_dataset(ctx, slice_ds)
         self.assertEqual({"a", "b", "c"}, set(tailored_ds.variables.keys()))
 
         b = tailored_ds.b
@@ -110,12 +114,72 @@ def test_it_completes_vars(self):
         self.assertEqual(np.dtype("uint32"), c.dtype)
         self.assertEqual(np.dtype("uint32"), c.encoding.get("dtype"))
 
-    # noinspection PyMethodMayBeStatic
+    def test_it_updates_attrs_according_to_update_mode(self):
+        target_ds = xr.Dataset(
+            {
+                "a": xr.DataArray(
+                    np.zeros((2, 3, 4)),
+                    dims=("time", "y", "x"),
+                ),
+            },
+            attrs={"Conventions": "CF-1.8"},
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "keep", "attrs": {"a": 12, "b": True}}, target_ds
+        )
+        tailored_ds = tailor_target_dataset(ctx, target_ds)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "replace", "attrs": {"a": 12, "b": True}}, target_ds
+        )
+        tailored_ds = tailor_target_dataset(ctx, target_ds)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "update", "attrs": {"a": 12, "b": True}}, target_ds
+        )
+        tailored_ds = tailor_target_dataset(ctx, target_ds)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "ignore", "attrs": {"a": 12, "b": True}}, target_ds
+        )
+        tailored_ds = tailor_target_dataset(ctx, target_ds)
+        self.assertEqual(
+            {
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
 
 
 class TailorSliceDatasetTest(unittest.TestCase):
     def test_it_drops_constant_variables(self):
-        ds = xr.Dataset(
+        slice_ds = xr.Dataset(
             {
                 "a": xr.DataArray(np.zeros((2, 3, 4)), dims=("time", "y", "x")),
                 "b": xr.DataArray(np.zeros((2, 3, 4)), dims=("time", "y", "x")),
@@ -128,14 +192,13 @@ def test_it_drops_constant_variables(self):
                 "y": xr.DataArray(np.linspace(0.0, 1.0, 3), dims="y"),
             },
         )
-        tailored_ds = tailor_slice_dataset(
-            ds, DatasetMetadata.from_dataset(ds, {}), "time"
-        )
+        ctx = make_context({}, slice_ds)
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
         self.assertIsInstance(tailored_ds, xr.Dataset)
         self.assertEqual({"a", "b"}, set(tailored_ds.variables.keys()))
 
-    def test_it_clears_encoding_and_attrs(self):
-        ds = xr.Dataset(
+    def test_it_clears_var_encoding_and_attrs(self):
+        slice_ds = xr.Dataset(
             {
                 "a": xr.DataArray(
                     np.zeros((2, 3, 4)),
@@ -149,19 +212,16 @@ def test_it_clears_encoding_and_attrs(self):
                 ),
             }
         )
-        tailored_ds = tailor_slice_dataset(
-            ds,
-            DatasetMetadata.from_dataset(
-                ds,
-                {
-                    "variables": {
-                        "a": {"encoding": {"dtype": "uint8", "fill_value": 0}},
-                        "b": {"encoding": {"dtype": "int8", "fill_value": -1}},
-                    }
-                },
-            ),
-            "time",
+        ctx = make_context(
+            {
+                "variables": {
+                    "a": {"encoding": {"dtype": "uint8", "fill_value": 0}},
+                    "b": {"encoding": {"dtype": "int8", "fill_value": -1}},
+                }
+            },
+            slice_ds,
         )
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
         self.assertIsInstance(tailored_ds, xr.Dataset)
 
         self.assertIn("a", tailored_ds.variables)
@@ -173,3 +233,83 @@ def test_it_clears_encoding_and_attrs(self):
         b = tailored_ds.b
         self.assertEqual({}, b.encoding)
         self.assertEqual({}, b.attrs)
+
+    def test_it_updates_attrs_according_to_update_mode(self):
+        target_ds = xr.Dataset(
+            {
+                "a": xr.DataArray(
+                    np.zeros((2, 3, 4)),
+                    dims=("time", "y", "x"),
+                ),
+            },
+            attrs={"Conventions": "CF-1.8"},
+        )
+        slice_ds = xr.Dataset(
+            {
+                "a": xr.DataArray(
+                    np.zeros((2, 3, 4)),
+                    dims=("time", "y", "x"),
+                ),
+            },
+            attrs={"title": "OCC 2024"},
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "keep", "attrs": {"a": 12, "b": True}},
+            target_ds,
+            True,
+        )
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "replace", "attrs": {"a": 12, "b": True}},
+            target_ds,
+            True,
+        )
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
+        self.assertEqual(
+            {
+                "title": "OCC 2024",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "update", "attrs": {"a": 12, "b": True}},
+            target_ds,
+            True,
+        )
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
+        self.assertEqual(
+            {
+                "Conventions": "CF-1.8",
+                "title": "OCC 2024",
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
+
+        ctx = make_context(
+            {"attrs_update_mode": "ignore", "attrs": {"a": 12, "b": True}},
+            target_ds,
+            True,
+        )
+        tailored_ds = tailor_slice_dataset(ctx, slice_ds)
+        self.assertEqual(
+            {
+                "a": 12,
+                "b": True,
+            },
+            tailored_ds.attrs,
+        )
diff --git a/zappend/config/__init__.py b/zappend/config/__init__.py
index 4abbed3..34da5e3 100644
--- a/zappend/config/__init__.py
+++ b/zappend/config/__init__.py
@@ -2,6 +2,9 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
+from .attrs import eval_dyn_config_attrs
+from .attrs import has_dyn_config_attrs
+from .attrs import get_dyn_config_attrs_env
 from .config import ConfigItem
 from .config import ConfigLike
 from .config import ConfigList
@@ -11,6 +14,7 @@
 from .config import validate_config
 from .defaults import DEFAULT_APPEND_DIM
 from .defaults import DEFAULT_APPEND_STEP
+from .defaults import DEFAULT_ATTRS_UPDATE_MODE
 from .defaults import DEFAULT_SLICE_POLLING_INTERVAL
 from .defaults import DEFAULT_SLICE_POLLING_TIMEOUT
 from .defaults import DEFAULT_ZARR_VERSION
diff --git a/zappend/config/attrs.py b/zappend/config/attrs.py
new file mode 100644
index 0000000..30b2aa7
--- /dev/null
+++ b/zappend/config/attrs.py
@@ -0,0 +1,186 @@
+# Copyright © 2024 Norman Fomferra
+# Permissions are hereby granted under the terms of the MIT License:
+# https://opensource.org/licenses/MIT.
+
+import datetime
+import math
+from typing import Any, Literal
+
+import numpy as np
+import xarray as xr
+
+
+def has_dyn_config_attrs(attrs: dict[str, Any]) -> bool:
+    """Check if given configuration `attrs` contain dynamically computed values.
+    Note, the check is currently performed only on top level attributes.
+
+    Args:
+        attrs: The dictionary of attributes from configuration.
+    Returns:
+         `True`, if so.
+    """
+    return any(isinstance(v, str) and "{{" in v and "}}" in v for v in attrs.values())
+
+
+def eval_dyn_config_attrs(attrs: dict[str, Any], env: dict[str, Any]):
+    """Replace any expression found in string-valued configuration attributes
+    `attrs` by dynamically computed values.
+    Note, the replacement is currently performed only on top level attributes.
+
+    WARNING: This method uses Python's evil `eval()` function. The caller is
+    responsible for avoiding that `env` causes a potential vulnerability
+    and prohibits insecure operations!
+
+    Args:
+        attrs: The dictionary of attributes from configuration.
+        env: The environment used for the evaluation.
+    Returns:
+         `True`, if so.
+    """
+    return {k: eval_attr_value(v, env) for k, v in attrs.items()}
+
+
+def eval_attr_value(attr_value: Any, env: dict[str, Any]) -> Any:
+    if isinstance(attr_value, str):
+        parts = attr_value.split("{{")
+        if len(parts) > 0:
+            values = []
+            for part in parts[1:]:
+                sub_parts = part.split("}}")
+                if len(sub_parts) == 2:
+                    expr, rest = sub_parts
+                    value = eval_expr(expr, env)
+                    if rest:
+                        value = str(value) + rest
+                    values.append(value)
+            if values:
+                if parts[0]:
+                    values[0] = parts[0] + str(values[0])
+                if len(values) == 1:
+                    return values[0]
+                else:
+                    return "".join(map(str, values))
+    return attr_value
+
+
+def eval_expr(expr: str, env: dict[str, Any]) -> Any:
+    value = eval(expr, env)
+    return to_json(value)
+
+
+def to_json(value) -> Any:
+    if isinstance(value, float):
+        if math.isfinite(value):
+            return value
+        else:
+            return str(value)
+    if isinstance(value, (bool, int, str, type(None))):
+        return value
+    if isinstance(value, datetime.date):
+        if isinstance(value, datetime.datetime):
+            return value.replace(microsecond=0).isoformat()
+        else:
+            return value.isoformat()
+
+    try:
+        if value.ndim == 0:
+            try:
+                # xarray.DataArray case
+                value = value.values
+            except AttributeError:
+                pass
+            if np.issubdtype(value.dtype, np.floating):
+                if np.isfinite(value):
+                    return float(value)
+                else:
+                    return str(value)
+            if np.issubdtype(value.dtype, np.bool_):
+                return bool(value)
+            if np.issubdtype(value.dtype, np.str_):
+                return str(value)
+            if np.issubdtype(value.dtype, np.integer):
+                return int(value)
+            if np.issubdtype(value.dtype, np.datetime64):
+                return np.datetime_as_string(value, unit="s")
+            raise ValueError(
+                f"cannot serialize 0-d array of type {type(value)}, dtype={value.dtype!r}"
+            )
+    except AttributeError:
+        pass
+
+    if isinstance(value, dict):
+        return {k: to_json(v) for k, v in value.items()}
+
+    try:
+        values = iter(value)
+    except TypeError:
+        raise ValueError(f"cannot serialize value of type {type(value)}")
+
+    return [to_json(v) for v in values]
+
+
+def get_dyn_config_attrs_env(ds: xr.Dataset, **kwargs):
+    return dict(
+        ds=ds,
+        **{
+            k: v
+            for k, v in ConfigAttrsUserFunctions.__dict__.items()
+            if not k.startswith("_")
+        },
+        **kwargs,
+    )
+
+
+_CellRef = Literal["lower"] | Literal["center"] | Literal["upper"]
+
+
+class ConfigAttrsUserFunctions:
+    """User functions that can be used within attribute expressions."""
+
+    @staticmethod
+    def lower_bound(array: xr.DataArray | np.ndarray, ref: _CellRef = "lower"):
+        """Get the lower bound of one-dimensional `array`.
+
+        Args:
+            array: numpy ndarray-like array
+            ref: cell reference
+                 - `"lower"` the cell lower bound
+                 - `"upper"` the cell upper bound
+                 - `"center"` the cell center
+        Return:
+            The lower bound of `array`.
+        """
+        return _bounds(array, ref)[0]
+
+    @staticmethod
+    def upper_bound(array: xr.DataArray | np.ndarray, ref: _CellRef = "lower"):
+        """Get the upper bound of one-dimensional `array`.
+
+        Args:
+            array: numpy ndarray-like array
+            ref: cell reference
+                 - `"lower"` the cell lower bound
+                 - `"upper"` the cell upper bound
+                 - `"center"` the cell center
+        Return:
+            The lower bound of `array`.
+        """
+        return _bounds(array, ref)[1]
+
+
+def _bounds(array: xr.DataArray | np.ndarray, ref: _CellRef):
+    if len(array.shape) != 1:
+        raise ValueError(f"array must be 1-dimensional, got shape {array.shape}")
+    if array.shape[0] == 0:
+        raise ValueError(f"array must not be empty")
+    v1, v2 = array[0], array[-1]
+    delta = array[1] - v1 if array.size > 1 else 0
+    if v1 > v2:
+        v1, v2 = v2, v1
+        delta = -delta
+    if ref == "lower":
+        return v1, v2 + delta
+    elif ref == "upper":
+        return v1 - delta, v2
+    else:
+        return v1 - delta / 2, v2 + delta / 2
diff --git a/zappend/config/defaults.py b/zappend/config/defaults.py
index ac00e53..05fe37d 100644
--- a/zappend/config/defaults.py
+++ b/zappend/config/defaults.py
@@ -5,6 +5,6 @@
 DEFAULT_ZARR_VERSION = 2
 DEFAULT_APPEND_DIM = "time"
 DEFAULT_APPEND_STEP = None
-
+DEFAULT_ATTRS_UPDATE_MODE = "keep"
 DEFAULT_SLICE_POLLING_INTERVAL = 2
 DEFAULT_SLICE_POLLING_TIMEOUT = 60
diff --git a/zappend/config/schema.py b/zappend/config/schema.py
index 7608636..3d2e616 100644
--- a/zappend/config/schema.py
+++ b/zappend/config/schema.py
@@ -7,6 +7,7 @@
 
 from .defaults import DEFAULT_APPEND_DIM
 from .defaults import DEFAULT_APPEND_STEP
+from .defaults import DEFAULT_ATTRS_UPDATE_MODE
 from .defaults import DEFAULT_SLICE_POLLING_INTERVAL
 from .defaults import DEFAULT_SLICE_POLLING_TIMEOUT
 from .defaults import DEFAULT_ZARR_VERSION
@@ -178,7 +179,7 @@
             },
             encoding=VARIABLE_ENCODING_SCHEMA,
             attrs={
-                "description": "Arbitrary variable metadata" " attributes.",
+                "description": "Arbitrary variable metadata attributes.",
                 "type": "object",
                 "additionalProperties": True,
             },
@@ -541,6 +542,65 @@
             "items": {"type": "string", "minLength": 1},
         },
         variables=VARIABLES_SCHEMA,
+        attrs={
+            "description": (
+                "Arbitrary dataset attributes."
+                " If `permit_eval` is set to `true`,"
+                " string values may include Python expressions"
+                " enclosed in `{{` and `}}` to dynamically compute"
+                " attribute values; in the expression, the current dataset "
+                " is named `ds`."
+                " Refer to the user guide for more information."
+            ),
+            "type": "object",
+            "additionalProperties": True,
+        },
+        attrs_update_mode={
+            "description": (
+                "The mode used update target attributes from slice"
+                " attributes. Independently of this setting, extra attributes"
+                " configured by the `attrs` setting will finally be used to"
+                " update the resulting target attributes."
+            ),
+            "oneOf": [
+                {
+                    "description": (
+                        "Use attributes from first slice dataset and keep them."
+                    ),
+                    "const": "keep",
+                },
+                {
+                    "description": (
+                        "Replace existing attributes by attributes"
+                        " of last slice dataset."
+                    ),
+                    "const": "replace",
+                },
+                {
+                    "description": (
+                        "Update existing attributes by attributes"
+                        " of last slice dataset."
+                    ),
+                    "const": "update",
+                },
+                {
+                    "description": "Ignore attributes from slice datasets.",
+                    "const": "ignore",
+                },
+            ],
+            "default": DEFAULT_ATTRS_UPDATE_MODE,
+        },
+        permit_eval={
+            "description": (
+                "Allow for dynamically computed values in dataset attributes"
+                " `attrs` using the syntax `{{ expression }}`. "
+                " Executing arbitrary Python expressions is a security"
+                " risk, therefore this must be explicitly enabled."
+                " Refer to the user guide for more information."
+            ),
+            "type": "boolean",
+            "default": False,
+        },
         target_dir={
             "description": (
                 "The URI or local path of the target Zarr dataset."
@@ -562,8 +622,8 @@
                 " a slice source for each slice item. If a class is given, it must be "
                 " derived from `zappend.api.SliceSource`."
                 " If a function is given, it must return an instance of "
-                " `zappend.api.SliceSource`. Refer to the user guide for more"
-                " information."
+                " `zappend.api.SliceSource`."
+                " Refer to the user guide for more information."
             ),
             "type": "string",
             "minLength": 1,
diff --git a/zappend/context.py b/zappend/context.py
index 4bdd8a3..9ab0ae5 100644
--- a/zappend/context.py
+++ b/zappend/context.py
@@ -3,12 +3,13 @@
 # https://opensource.org/licenses/MIT.
 
 import tempfile
-from typing import Any, Dict
+from typing import Any, Dict, Literal
 
 import xarray as xr
 
 from .config import DEFAULT_APPEND_DIM
 from .config import DEFAULT_APPEND_STEP
+from .config import DEFAULT_ATTRS_UPDATE_MODE
 from .config import DEFAULT_SLICE_POLLING_INTERVAL
 from .config import DEFAULT_SLICE_POLLING_TIMEOUT
 from .config import DEFAULT_ZARR_VERSION
@@ -36,16 +37,16 @@ def __init__(self, config: Dict[str, Any]):
         target_storage_options = config.get("target_storage_options")
         self._target_dir = FileObj(target_uri, storage_options=target_storage_options)
 
-        self._append_dim_name = config.get("append_dim") or DEFAULT_APPEND_DIM
-        self._append_step_size = config.get("append_step") or DEFAULT_APPEND_STEP
+        self._append_dim = config.get("append_dim") or DEFAULT_APPEND_DIM
+        self._append_step = config.get("append_step") or DEFAULT_APPEND_STEP
         self._last_append_label = None
 
         try:
             with xr.open_zarr(
                 target_uri, storage_options=target_storage_options
             ) as target_dataset:
-                if self.append_step_size is not None:
-                    append_var = target_dataset.get(self._append_dim_name)
+                if self.append_step is not None:
+                    append_var = target_dataset.get(self._append_dim)
                     if append_var is not None and append_var.size > 0:
                         self._last_append_label = append_var[-1]
                 target_metadata = DatasetMetadata.from_dataset(target_dataset, config)
@@ -80,18 +81,34 @@ def zarr_version(self) -> int:
         return self._config.get("zarr_version", DEFAULT_ZARR_VERSION)
 
     @property
-    def append_dim_name(self) -> str:
+    def append_dim(self) -> str:
         """The name of the append dimension along which slice datasets will be
         concatenated. Defaults to `"time"`.
         """
-        return self._append_dim_name
+        return self._append_dim
 
     @property
-    def append_step_size(self) -> int | float | str | None:
+    def append_step(self) -> int | float | str | None:
         """The enforced step size in the append dimension between two slices.
         Defaults to `None`.
         """
-        return self._append_step_size
+        return self._append_step
+
+    @property
+    def attrs(self) -> dict[str, Any]:
+        """Global dataset attributes. May include dynamically computed
+        placeholders if the form `{{ expression }}`.
+        """
+        return self._config.get("attrs") or {}
+
+    @property
+    def attrs_update_mode(
+        self,
+    ) -> Literal["keep"] | Literal["replace"] | Literal["update"]:
+        """The mode used to deal with global slice dataset attributes.
+        One of `"keep"`, `"replace"`, `"update"`.
+        """
+        return self._config.get("attrs_update_mode") or DEFAULT_ATTRS_UPDATE_MODE
 
     @property
     def last_append_label(self) -> Any | None:
@@ -101,6 +118,15 @@ def last_append_label(self) -> Any | None:
         """
         return self._last_append_label
 
+    @property
+    def permit_eval(self) -> bool:
+        """Check if dynamically computed values in dataset attributes `attrs`
+        using the syntax `{{ expression }}` is permitted. Executing arbitrary
+        Python expressions is a security risk, therefore this must be explicitly
+        enabled.
+        """
+        return bool(self._config.get("permit_eval"))
+
     @property
     def target_metadata(self) -> DatasetMetadata | None:
         """The metadata for the target dataset. May be `None` while the
diff --git a/zappend/processor.py b/zappend/processor.py
index a5f505f..8d351f4 100644
--- a/zappend/processor.py
+++ b/zappend/processor.py
@@ -2,15 +2,21 @@
 # Permissions are hereby granted under the terms of the MIT License:
 # https://opensource.org/licenses/MIT.
 
-from typing import Iterable
+from typing import Iterable, Any
+import collections.abc
 
 import numpy as np
 import xarray as xr
+import zarr.attrs
+import zarr.convenience
 
 from .config import ConfigLike
 from .config import exclude_from_config
 from .config import normalize_config
 from .config import validate_config
+from .config import eval_dyn_config_attrs
+from .config import get_dyn_config_attrs_env
+from .config import has_dyn_config_attrs
 from .context import Context
 from .fsutil.transaction import Transaction
 from .fsutil.transaction import RollbackCallback
@@ -20,8 +26,8 @@
 from .rollbackstore import RollbackStore
 from .slice import SliceObj
 from .slice import open_slice_source
-from .tailoring import tailor_target_dataset
 from .tailoring import tailor_slice_dataset
+from .tailoring import tailor_target_dataset
 
 
 class Processor:
@@ -98,7 +104,7 @@ def process_slice(self, slice_obj: SliceObj, slice_index: int = 0):
                 ctx.target_metadata = slice_metadata
             else:
                 ctx.target_metadata.assert_compatible_slice(
-                    slice_metadata, ctx.append_dim_name
+                    slice_metadata, ctx.append_dim
                 )
 
             verify_append_labels(ctx, slice_dataset)
@@ -118,11 +124,12 @@ def create_target_from_slice(
 ):
     target_dir = ctx.target_dir
     logger.info(f"Creating target dataset {target_dir.uri}")
-    target_ds = tailor_target_dataset(slice_ds, ctx.target_metadata)
+
+    target_ds = tailor_target_dataset(ctx, slice_ds)
+
     if ctx.dry_run:
         return
-    # TODO: adjust global attributes dependent on append_dim,
-    #  e.g., time coverage
+
     try:
         target_ds.to_zarr(
             store=target_dir.uri,
@@ -131,6 +138,9 @@ def create_target_from_slice(
             write_empty_chunks=False,
             consolidated=True,
         )
+
+        post_create_target(ctx, target_ds)
+
     finally:
         if target_dir.exists():
             rollback_cb("delete_dir", "", None)
@@ -141,34 +151,76 @@ def update_target_from_slice(
 ):
     target_dir = ctx.target_dir
     logger.info(f"Updating target dataset {target_dir.uri}")
-    append_dim_name = ctx.append_dim_name
 
-    slice_ds = tailor_slice_dataset(slice_ds, ctx.target_metadata, append_dim_name)
+    slice_ds = tailor_slice_dataset(ctx, slice_ds)
 
     if ctx.dry_run:
         return
 
-    # TODO: adjust global attributes dependent on append_dim,
-    #  e.g., time coverage
-
-    store = RollbackStore(target_dir.fs.get_mapper(root=target_dir.path), rollback_cb)
+    target_store = RollbackStore(
+        target_dir.fs.get_mapper(root=target_dir.path), rollback_cb
+    )
     slice_ds.to_zarr(
-        store=store,
+        store=target_store,
         write_empty_chunks=False,
         consolidated=True,
         mode="a",
-        append_dim=append_dim_name,
+        append_dim=ctx.append_dim,
+    )
+
+    post_update_target(ctx, target_store, slice_ds)
+
+
+def post_create_target(ctx: Context, target_ds: xr.Dataset):
+    """Post-process the target dataset given by `target_ds`
+    that has just been created.
+
+    Args:
+        ctx: Current processing context.
+        target_ds: The target dataset.
+    """
+    target_attrs = target_ds.attrs
+    if ctx.permit_eval and has_dyn_config_attrs(target_attrs):
+        target_store = ctx.target_dir.fs.get_mapper(root=ctx.target_dir.path)
+        resolve_target_attrs(target_store, target_ds, target_attrs)
+
+
+def post_update_target(ctx: Context, target_store: RollbackStore, slice_ds: xr.Dataset):
+    """Post-process the target dataset given by `target_store` that has just
+    been updated by `slice_ds`.
+
+    Args:
+        ctx: Current processing context.
+        target_store: The target dataset as a rollback store.
+        slice_ds: The current slice dataset that has already been appended.
+    """
+    target_attrs = slice_ds.attrs
+    if ctx.permit_eval and has_dyn_config_attrs(target_attrs):
+        with xr.open_zarr(target_store) as target_ds:
+            resolve_target_attrs(target_store, target_ds, target_attrs)
+
+
+def resolve_target_attrs(
+    target_store: collections.abc.MutableMapping,
+    target_ds: xr.Dataset,
+    target_attrs: dict[str, Any],
+):
+    resolved_attrs = eval_dyn_config_attrs(
+        target_attrs,
+        get_dyn_config_attrs_env(target_ds),
     )
+    zarr.attrs.Attributes(target_store).update(resolved_attrs)
+    # noinspection PyTypeChecker
+    zarr.convenience.consolidate_metadata(target_store)
 
 
 def verify_append_labels(ctx: Context, slice_ds: xr.Dataset):
-    append_step_size = ctx.append_step_size
+    append_step_size = ctx.append_step
     if append_step_size is None:
         # If step size is not specified, there is nothing to do
         return
 
-    append_dim_name = ctx.append_dim_name
-    append_labels: xr.DataArray = slice_ds.get(append_dim_name)
+    append_labels: xr.DataArray = slice_ds.get(ctx.append_dim)
     if append_labels is None:
         # It is ok to not have append-labels in the dataset
         return
diff --git a/zappend/tailoring.py b/zappend/tailoring.py
index d4c5dad..400f138 100644
--- a/zappend/tailoring.py
+++ b/zappend/tailoring.py
@@ -6,56 +6,82 @@
 import numpy as np
 import xarray as xr
 
-from .metadata import DatasetMetadata
+from .context import Context
 from .log import logger
+from .metadata import DatasetMetadata
 
 
-def tailor_target_dataset(
-    dataset: xr.Dataset, target_metadata: DatasetMetadata
-) -> xr.Dataset:
-    dataset = _strip_dataset(dataset, target_metadata)
-    dataset = _complete_dataset(dataset, target_metadata)
+def tailor_target_dataset(ctx: Context, slice_ds: xr.Dataset) -> xr.Dataset:
+    target_metadata = ctx.target_metadata
+    attrs_update_mode = ctx.attrs_update_mode
+    attrs = ctx.attrs
+
+    target_ds = _strip_dataset(slice_ds, target_metadata)
+    target_ds = _complete_dataset(target_ds, target_metadata)
+
+    # TODO: use ctx.attrs_update_mode to set initial dataset attributes
 
-    # Complement dataset attributes and set
-    # variable encoding and attributes
-    dataset.attrs = target_metadata.attrs
+    # Set initial dataset attributes
+    if attrs_update_mode == "ignore":
+        # Ignore attributes from slice dataset
+        target_ds.attrs = {}
+    else:
+        target_ds.attrs = target_metadata.attrs
+    if attrs:
+        # Always update by configured attributes
+        target_ds.attrs.update(attrs)
+
+    # Set variable encoding and attributes
     for var_name, var_metadata in target_metadata.variables.items():
-        variable = dataset.variables[var_name]
+        variable = target_ds.variables[var_name]
         variable.encoding = var_metadata.encoding.to_dict()
         variable.attrs = var_metadata.attrs
-    return dataset
 
+    return target_ds
 
-def tailor_slice_dataset(
-    dataset: xr.Dataset, target_metadata: DatasetMetadata, append_dim_name: str
-) -> xr.Dataset:
-    dataset = _strip_dataset(dataset, target_metadata)
-    dataset = _complete_dataset(dataset, target_metadata)
+
+def tailor_slice_dataset(ctx: Context, slice_ds: xr.Dataset) -> xr.Dataset:
+    target_metadata = ctx.target_metadata
+    append_dim = ctx.append_dim
+    attrs_update_mode = ctx.attrs_update_mode
+    attrs = ctx.attrs
+
+    slice_ds = _strip_dataset(slice_ds, target_metadata)
+    slice_ds = _complete_dataset(slice_ds, target_metadata)
 
     const_variables = [
-        k for k, v in dataset.variables.items() if append_dim_name not in v.dims
+        k for k, v in slice_ds.variables.items() if append_dim not in v.dims
     ]
     if const_variables:
-        # Strip variables that do not have append_dim_name
+        # Strip variables that do not have append_dim
         # as dimension, e.g., "x", "y", "crs", ...
-        dataset = dataset.drop_vars(const_variables)
+        slice_ds = slice_ds.drop_vars(const_variables)
 
     # https://github.com/bcdev/zappend/issues/56
     # slice_dataset.to_zarr(store, mode="a", ...) will replace
     # global attributes.
-    # Therefore, we must replace slice dataset attributes by
-    # existing target dataset attributes.
-    # However, users should be able to select the appropriate
-    # operation, e.g., a new config setting target_attrs_op with
-    # values "first" (default), "last", "update".
-    dataset.attrs = target_metadata.attrs
+    # Therefore, we must take care how slice dataset attributes
+    # are updated.
+    # If attrs_update_op is "replace", we just keep slice attributes
+    if attrs_update_mode == "keep":
+        # Keep existing attributes
+        slice_ds.attrs = target_metadata.attrs
+    elif attrs_update_mode == "update":
+        # Update from last slice dataset
+        slice_ds.attrs = target_metadata.attrs | slice_ds.attrs
+    elif attrs_update_mode == "ignore":
+        # Ignore attributes from slice dataset
+        slice_ds.attrs = {}
+    if attrs:
+        # Always update by configured attributes
+        slice_ds.attrs.update(attrs)
 
     # Remove any encoding and attributes from slice,
     # since both are prescribed by target
-    for variable in dataset.variables.values():
+    for variable in slice_ds.variables.values():
         variable.encoding = {}
         variable.attrs = {}
-    return dataset
+    return slice_ds
 
 
 def _strip_dataset(dataset: xr.Dataset, target_metadata: DatasetMetadata) -> xr.Dataset:
