Repository: zarr-developers/zarr-python

Issue:
[v3] Re-opening an existing RemoteStore V3 group causes the first child to be deleted
This is a weird and unexpected behavior 

```python
import xarray as xr
import zarr
import s3fs
print(xr.__version__, zarr.__version__)
# -> ('0.9.7.dev3734+g26081d4f', '3.0.0b1.dev8+g9bbfd88')

s3 = s3fs.S3FileSystem()
# replace with your favorite bucket
target_path = "icechunk-test/ryan/zarr-v3/test-92"
store = zarr.storage.RemoteStore(s3, mode="w", path=target_path)

# create a group with two children
group = zarr.open_group(store=store, mode="w", zarr_format=3)
group.create("A", shape=1)
group.create("B", shape=1)
print(group.members())
#  (('A',
#  <Array <RemoteStore(S3FileSystem, icechunk-test/ryan/zarr-v3/test-era5-v3-92)>/A shape=(1,) dtype=float64>),
#  ('B',
#   <Array <RemoteStore(S3FileSystem, icechunk-test/ryan/zarr-v3/test-era5-v3-92)>/B shape=(1,) dtype=float64>))

# re-open the group
store2 = zarr.storage.RemoteStore(s3, mode="w", path=target_path)
group = zarr.open_group(store=store2, mode="w", zarr_format=3)
print(group.members())
# (('B',
#  <Array <RemoteStore(S3FileSystem, icechunk-test/ryan/zarr-v3/test-era5-v3-92)>/B shape=(1,) dtype=float64>),)
```

Array `A` has disappeared! That's bad!

This might have something to do with #2338. Really not sure. I haven't gotten to the bottom of it, but I thought I would post a report anyway.

Discussion:

Patch:
diff --git a/docs/guide/storage.rst b/docs/guide/storage.rst
index 0019f993..69de796b 100644
--- a/docs/guide/storage.rst
+++ b/docs/guide/storage.rst
@@ -2,7 +2,7 @@ Storage
 =======
 
 Zarr-Python supports multiple storage backends, including: local file systems,
-Zip files, remote stores via ``fspec`` (S3, HTTP, etc.), and in-memory stores. In
+Zip files, remote stores via ``fsspec`` (S3, HTTP, etc.), and in-memory stores. In
 Zarr-Python 3, stores must implement the abstract store API from
 :class:`zarr.abc.store.Store`. 
 
@@ -19,9 +19,9 @@ to Zarr's top level API will result in the store being created automatically.
 .. code-block:: python
 
    >>> import zarr
-   >>> zarr.open("data/foo/bar", mode="r")  # implicitly creates a LocalStore
+   >>> zarr.open("data/foo/bar", mode="r")  # implicitly creates a read-only LocalStore
    <Group file://data/foo/bar>
-   >>> zarr.open("s3://foo/bar", mode="r")  # implicitly creates a RemoteStore
+   >>> zarr.open("s3://foo/bar", mode="r")  # implicitly creates a read-only RemoteStore
    <Group s3://foo/bar>
    >>> data = {}
    >>> zarr.open(data, mode="w")  # implicitly creates a MemoryStore
@@ -43,7 +43,7 @@ filesystem.
 .. code-block:: python
 
    >>> import zarr
-   >>> store = zarr.storage.LocalStore("data/foo/bar", mode="r")
+   >>> store = zarr.storage.LocalStore("data/foo/bar", read_only=True)
    >>> zarr.open(store=store)
    <Group file://data/foo/bar>
 
@@ -72,7 +72,7 @@ that implements the `AbstractFileSystem` API,
 .. code-block:: python
 
    >>> import zarr
-   >>> store = zarr.storage.RemoteStore.from_url("gs://foo/bar", mode="r")
+   >>> store = zarr.storage.RemoteStore.from_url("gs://foo/bar", read_only=True)
    >>> zarr.open(store=store)
    <Array <RemoteStore(GCSFileSystem, foo/bar)> shape=(10, 20) dtype=float32>
 
@@ -86,7 +86,7 @@ Zarr data (metadata and chunks) to a dictionary.
 
    >>> import zarr
    >>> data = {}
-   >>> store = zarr.storage.MemoryStore(data, mode="w")
+   >>> store = zarr.storage.MemoryStore(data)
    >>> zarr.open(store=store, shape=(2, ))
    <Array memory://4943638848 shape=(2,) dtype=float64>
 
diff --git a/src/zarr/abc/store.py b/src/zarr/abc/store.py
index e7acce78..fa3c7f3b 100644
--- a/src/zarr/abc/store.py
+++ b/src/zarr/abc/store.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 from abc import ABC, abstractmethod
 from asyncio import gather
 from itertools import starmap
-from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
+from typing import TYPE_CHECKING, Protocol, runtime_checkable
 
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator, AsyncIterator, Iterable
@@ -11,64 +11,24 @@ if TYPE_CHECKING:
     from typing import Any, Self, TypeAlias
 
     from zarr.core.buffer import Buffer, BufferPrototype
-    from zarr.core.common import AccessModeLiteral, BytesLike
+    from zarr.core.common import BytesLike
 
-__all__ = ["AccessMode", "ByteGetter", "ByteSetter", "Store", "set_or_delete"]
+__all__ = ["ByteGetter", "ByteSetter", "Store", "set_or_delete"]
 
 ByteRangeRequest: TypeAlias = tuple[int | None, int | None]
 
 
-class AccessMode(NamedTuple):
-    """Access mode flags."""
-
-    str: AccessModeLiteral
-    readonly: bool
-    overwrite: bool
-    create: bool
-    update: bool
-
-    @classmethod
-    def from_literal(cls, mode: AccessModeLiteral) -> Self:
-        """
-        Create an AccessMode instance from a literal.
-
-        Parameters
-        ----------
-        mode : AccessModeLiteral
-            One of 'r', 'r+', 'w', 'w-', 'a'.
-
-        Returns
-        -------
-        AccessMode
-            The created instance.
-
-        Raises
-        ------
-        ValueError
-            If mode is not one of 'r', 'r+', 'w', 'w-', 'a'.
-        """
-        if mode in ("r", "r+", "a", "w", "w-"):
-            return cls(
-                str=mode,
-                readonly=mode == "r",
-                overwrite=mode == "w",
-                create=mode in ("a", "w", "w-"),
-                update=mode in ("r+", "a"),
-            )
-        raise ValueError("mode must be one of 'r', 'r+', 'w', 'w-', 'a'")
-
-
 class Store(ABC):
     """
     Abstract base class for Zarr stores.
     """
 
-    _mode: AccessMode
+    _read_only: bool
     _is_open: bool
 
-    def __init__(self, *args: Any, mode: AccessModeLiteral = "r", **kwargs: Any) -> None:
+    def __init__(self, *, read_only: bool = False) -> None:
         self._is_open = False
-        self._mode = AccessMode.from_literal(mode)
+        self._read_only = read_only
 
     @classmethod
     async def open(cls, *args: Any, **kwargs: Any) -> Self:
@@ -112,19 +72,9 @@ class Store(ABC):
         ------
         ValueError
             If the store is already open.
-        FileExistsError
-            If ``mode='w-'`` and the store already exists.
-
-        Notes
-        -----
-        * When ``mode='w'`` and the store already exists, it will be cleared.
         """
         if self._is_open:
             raise ValueError("store is already open")
-        if self.mode.str == "w":
-            await self.clear()
-        elif self.mode.str == "w-" and not await self.empty():
-            raise FileExistsError("Store already exists")
         self._is_open = True
 
     async def _ensure_open(self) -> None:
@@ -132,61 +82,50 @@ class Store(ABC):
         if not self._is_open:
             await self._open()
 
-    @abstractmethod
-    async def empty(self) -> bool:
+    async def is_empty(self, prefix: str) -> bool:
         """
-        Check if the store is empty.
+        Check if the directory is empty.
+
+        Parameters
+        ----------
+        prefix : str
+            Prefix of keys to check.
 
         Returns
         -------
         bool
             True if the store is empty, False otherwise.
         """
-        ...
+        if not self.supports_listing:
+            raise NotImplementedError
+        if prefix != "" and not prefix.endswith("/"):
+            prefix += "/"
+        async for _ in self.list_prefix(prefix):
+            return False
+        return True
 
-    @abstractmethod
     async def clear(self) -> None:
         """
         Clear the store.
 
         Remove all keys and values from the store.
         """
-        ...
-
-    @abstractmethod
-    def with_mode(self, mode: AccessModeLiteral) -> Self:
-        """
-        Return a new store of the same type pointing to the same location with a new mode.
-
-        The returned Store is not automatically opened. Call :meth:`Store.open` before
-        using.
-
-        Parameters
-        ----------
-        mode : AccessModeLiteral
-            The new mode to use.
-
-        Returns
-        -------
-        store
-            A new store of the same type with the new mode.
-
-        Examples
-        --------
-        >>> writer = zarr.store.MemoryStore(mode="w")
-        >>> reader = writer.with_mode("r")
-        """
-        ...
+        if not self.supports_deletes:
+            raise NotImplementedError
+        if not self.supports_listing:
+            raise NotImplementedError
+        self._check_writable()
+        await self.delete_dir("")
 
     @property
-    def mode(self) -> AccessMode:
-        """Access mode of the store."""
-        return self._mode
+    def read_only(self) -> bool:
+        """Is the store read-only?"""
+        return self._read_only
 
     def _check_writable(self) -> None:
         """Raise an exception if the store is not writable."""
-        if self.mode.readonly:
-            raise ValueError("store mode does not support writing")
+        if self.read_only:
+            raise ValueError("store was opened in read-only mode and does not support writing")
 
     @abstractmethod
     def __eq__(self, value: object) -> bool:
@@ -385,7 +324,7 @@ class Store(ABC):
         if not self.supports_listing:
             raise NotImplementedError
         self._check_writable()
-        if not prefix.endswith("/"):
+        if prefix != "" and not prefix.endswith("/"):
             prefix += "/"
         async for key in self.list_prefix(prefix):
             await self.delete(key)
diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py
index 40f9b8d5..94ecfee4 100644
--- a/src/zarr/api/asynchronous.py
+++ b/src/zarr/api/asynchronous.py
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any, Literal, cast
 import numpy as np
 import numpy.typing as npt
 
-from zarr.abc.store import Store
 from zarr.core.array import Array, AsyncArray, get_array_metadata
 from zarr.core.buffer import NDArrayLike
 from zarr.core.common import (
@@ -24,7 +23,6 @@ from zarr.core.metadata import ArrayMetadataDict, ArrayV2Metadata, ArrayV3Metada
 from zarr.errors import NodeTypeValidationError
 from zarr.storage import (
     StoreLike,
-    StorePath,
     make_store_path,
 )
 
@@ -67,6 +65,18 @@ __all__ = [
 ]
 
 
+_READ_MODES: tuple[AccessModeLiteral, ...] = ("r", "r+", "a")
+_CREATE_MODES: tuple[AccessModeLiteral, ...] = ("a", "w", "w-")
+_OVERWRITE_MODES: tuple[AccessModeLiteral, ...] = ("a", "r+", "w")
+
+
+def _infer_exists_ok(mode: AccessModeLiteral) -> bool:
+    """
+    Check that an ``AccessModeLiteral`` is compatible with overwriting an existing Zarr node.
+    """
+    return mode in _OVERWRITE_MODES
+
+
 def _get_shape_chunks(a: ArrayLike | Any) -> tuple[ChunkCoords | None, ChunkCoords | None]:
     """Helper function to get the shape and chunks from an array-like object"""
     shape = None
@@ -252,7 +262,7 @@ async def load(
 async def open(
     *,
     store: StoreLike | None = None,
-    mode: AccessModeLiteral | None = None,  # type and value changed
+    mode: AccessModeLiteral = "a",
     zarr_version: ZarrFormat | None = None,  # deprecated
     zarr_format: ZarrFormat | None = None,
     path: str | None = None,
@@ -290,7 +300,8 @@ async def open(
 
     store_path = await make_store_path(store, mode=mode, path=path, storage_options=storage_options)
 
-    if "shape" not in kwargs and mode in {"a", "w", "w-"}:
+    # TODO: the mode check below seems wrong!
+    if "shape" not in kwargs and mode in {"a", "r", "r+"}:
         try:
             metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format)
             # TODO: remove this cast when we fix typing for array metadata dicts
@@ -300,17 +311,17 @@ async def open(
             is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array"
             if is_v3_array or zarr_format == 2:
                 return AsyncArray(store_path=store_path, metadata=_metadata_dict)
-        except (AssertionError, FileNotFoundError):
+        except (AssertionError, FileNotFoundError, NodeTypeValidationError):
             pass
         return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs)
 
     try:
-        return await open_array(store=store_path, zarr_format=zarr_format, **kwargs)
+        return await open_array(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs)
     except (KeyError, NodeTypeValidationError):
         # KeyError for a missing key
         # NodeTypeValidationError for failing to parse node metadata as an array when it's
         # actually a group
-        return await open_group(store=store_path, zarr_format=zarr_format, **kwargs)
+        return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs)
 
 
 async def open_consolidated(
@@ -396,18 +407,20 @@ async def save_array(
     if not isinstance(arr, NDArrayLike):
         raise TypeError("arr argument must be numpy or other NDArrayLike array")
 
-    mode = kwargs.pop("mode", None)
+    mode = kwargs.pop("mode", "a")
     store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
     if np.isscalar(arr):
         arr = np.array(arr)
     shape = arr.shape
     chunks = getattr(arr, "chunks", None)  # for array-likes with chunks attribute
+    exists_ok = kwargs.pop("exists_ok", None) or _infer_exists_ok(mode)
     new = await AsyncArray.create(
         store_path,
         zarr_format=zarr_format,
         shape=shape,
         dtype=arr.dtype,
         chunks=chunks,
+        exists_ok=exists_ok,
         **kwargs,
     )
     await new.setitem(slice(None), arr)
@@ -441,6 +454,9 @@ async def save_group(
     **kwargs
         NumPy arrays with data to save.
     """
+
+    store_path = await make_store_path(store, path=path, mode="w", storage_options=storage_options)
+
     zarr_format = (
         _handle_zarr_version_or_format(
             zarr_version=zarr_version,
@@ -465,7 +481,7 @@ async def save_group(
         _path = f"{path}/arr_{i}" if path is not None else f"arr_{i}"
         aws.append(
             save_array(
-                store,
+                store_path,
                 arr,
                 zarr_format=zarr_format,
                 path=_path,
@@ -473,12 +489,7 @@ async def save_group(
             )
         )
     for k, arr in kwargs.items():
-        _path = f"{path}/{k}" if path is not None else k
-        aws.append(
-            save_array(
-                store, arr, zarr_format=zarr_format, path=_path, storage_options=storage_options
-            )
-        )
+        aws.append(save_array(store_path, arr, zarr_format=zarr_format, path=k))
     await asyncio.gather(*aws)
 
 
@@ -588,8 +599,11 @@ async def group(
 
     zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
 
-    mode = None if isinstance(store, Store) else cast(AccessModeLiteral, "a")
-
+    mode: AccessModeLiteral
+    if overwrite:
+        mode = "w"
+    else:
+        mode = "r+"
     store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
 
     if chunk_store is not None:
@@ -607,9 +621,10 @@ async def group(
     try:
         return await AsyncGroup.open(store=store_path, zarr_format=zarr_format)
     except (KeyError, FileNotFoundError):
+        _zarr_format = zarr_format or _default_zarr_version()
         return await AsyncGroup.from_store(
             store=store_path,
-            zarr_format=zarr_format or _default_zarr_version(),
+            zarr_format=_zarr_format,
             exists_ok=overwrite,
             attributes=attributes,
         )
@@ -618,7 +633,7 @@ async def group(
 async def open_group(
     store: StoreLike | None = None,
     *,  # Note: this is a change from v2
-    mode: AccessModeLiteral | None = None,
+    mode: AccessModeLiteral = "a",
     cache_attrs: bool | None = None,  # not used, default changed
     synchronizer: Any = None,  # not used
     path: str | None = None,
@@ -709,16 +724,22 @@ async def open_group(
         attributes = {}
 
     try:
-        return await AsyncGroup.open(
-            store_path, zarr_format=zarr_format, use_consolidated=use_consolidated
-        )
+        if mode in _READ_MODES:
+            return await AsyncGroup.open(
+                store_path, zarr_format=zarr_format, use_consolidated=use_consolidated
+            )
     except (KeyError, FileNotFoundError):
+        pass
+    if mode in _CREATE_MODES:
+        exists_ok = _infer_exists_ok(mode)
+        _zarr_format = zarr_format or _default_zarr_version()
         return await AsyncGroup.from_store(
             store_path,
-            zarr_format=zarr_format or _default_zarr_version(),
-            exists_ok=True,
+            zarr_format=_zarr_format,
+            exists_ok=exists_ok,
             attributes=attributes,
         )
+    raise FileNotFoundError(f"Unable to find group: {store_path}")
 
 
 async def create(
@@ -861,6 +882,8 @@ async def create(
         warnings.warn("cache_attrs is not yet implemented", RuntimeWarning, stacklevel=2)
     if object_codec is not None:
         warnings.warn("object_codec is not yet implemented", RuntimeWarning, stacklevel=2)
+    if read_only is not None:
+        warnings.warn("read_only is not yet implemented", RuntimeWarning, stacklevel=2)
     if dimension_separator is not None:
         if zarr_format == 3:
             raise ValueError(
@@ -878,11 +901,9 @@ async def create(
         warnings.warn("meta_array is not yet implemented", RuntimeWarning, stacklevel=2)
 
     mode = kwargs.pop("mode", None)
-    if mode is None and not isinstance(store, Store | StorePath):
+    if mode is None:
         mode = "a"
-
     store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
-
     return await AsyncArray.create(
         store_path,
         shape=shape,
@@ -890,7 +911,7 @@ async def create(
         dtype=dtype,
         compressor=compressor,
         fill_value=fill_value,
-        exists_ok=overwrite,  # TODO: name change
+        exists_ok=overwrite,
         filters=filters,
         dimension_separator=dimension_separator,
         zarr_format=zarr_format,
@@ -1060,7 +1081,7 @@ async def open_array(
         If using an fsspec URL to create the store, these will be passed to
         the backend implementation. Ignored otherwise.
     **kwargs
-        Any keyword arguments to pass to the array constructor.
+        Any keyword arguments to pass to ``create``.
 
     Returns
     -------
@@ -1069,18 +1090,20 @@ async def open_array(
     """
 
     mode = kwargs.pop("mode", None)
-    store_path = await make_store_path(store, path=path, mode=mode)
+    store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
 
     zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
 
     try:
         return await AsyncArray.open(store_path, zarr_format=zarr_format)
     except FileNotFoundError:
-        if store_path.store.mode.create:
+        if not store_path.read_only:
+            exists_ok = _infer_exists_ok(mode)
+            _zarr_format = zarr_format or _default_zarr_version()
             return await create(
                 store=store_path,
-                zarr_format=zarr_format or _default_zarr_version(),
-                overwrite=store_path.store.mode.overwrite,
+                zarr_format=_zarr_format,
+                overwrite=exists_ok,
                 **kwargs,
             )
         raise
diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py
index 9dcd6fe2..9616c413 100644
--- a/src/zarr/api/synchronous.py
+++ b/src/zarr/api/synchronous.py
@@ -68,7 +68,7 @@ def load(
 def open(
     store: StoreLike | None = None,
     *,
-    mode: AccessModeLiteral | None = None,  # type and value changed
+    mode: AccessModeLiteral = "a",
     zarr_version: ZarrFormat | None = None,  # deprecated
     zarr_format: ZarrFormat | None = None,
     path: str | None = None,
@@ -199,8 +199,8 @@ def group(
 @_deprecate_positional_args
 def open_group(
     store: StoreLike | None = None,
-    *,  # Note: this is a change from v2
-    mode: AccessModeLiteral | None = None,  # not used in async api
+    *,
+    mode: AccessModeLiteral = "a",
     cache_attrs: bool | None = None,  # default changed, not used in async api
     synchronizer: Any = None,  # not used in async api
     path: str | None = None,
diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 20446abe..eeff8378 100644
--- a/src/zarr/core/array.py
+++ b/src/zarr/core/array.py
@@ -796,7 +796,7 @@ class AsyncArray(Generic[T_ArrayMetadata]):
             True if the array is read-only
         """
         # Backwards compatibility for 2.x
-        return self.store_path.store.mode.readonly
+        return self.store_path.read_only
 
     @property
     def path(self) -> str:
@@ -1384,7 +1384,7 @@ class AsyncArray(Generic[T_ArrayMetadata]):
             _zarr_format=self.metadata.zarr_format,
             _shape=self.shape,
             _order=self.order,
-            _read_only=self.store_path.store.mode.readonly,
+            _read_only=self.read_only,
             _store_type=type(self.store_path.store).__name__,
             _count_bytes=self.dtype.itemsize * self.size,
             **kwargs,
diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py
index 16030604..86d5d479 100644
--- a/src/zarr/core/group.py
+++ b/src/zarr/core/group.py
@@ -866,7 +866,7 @@ class AsyncGroup:
 
         return GroupInfo(
             _name=self.store_path.path,
-            _read_only=self.store_path.store.mode.readonly,
+            _read_only=self.read_only,
             _store_type=type(self.store_path.store).__name__,
             _zarr_format=self.metadata.zarr_format,
             # maybe do a typeddict
@@ -880,7 +880,7 @@ class AsyncGroup:
     @property
     def read_only(self) -> bool:
         # Backwards compatibility for 2.x
-        return self.store_path.store.mode.readonly
+        return self.store_path.read_only
 
     @property
     def synchronizer(self) -> None:
diff --git a/src/zarr/storage/common.py b/src/zarr/storage/common.py
index 1317ab79..0485a335 100644
--- a/src/zarr/storage/common.py
+++ b/src/zarr/storage/common.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal
 
 from zarr.abc.store import ByteRangeRequest, Store
 from zarr.core.buffer import Buffer, default_buffer_prototype
-from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZGROUP_JSON, ZarrFormat
+from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZGROUP_JSON, AccessModeLiteral, ZarrFormat
 from zarr.errors import ContainsArrayAndGroupError, ContainsArrayError, ContainsGroupError
 from zarr.storage._utils import normalize_path
 from zarr.storage.local import LocalStore
@@ -16,7 +16,6 @@ from zarr.storage.memory import MemoryStore
 
 if TYPE_CHECKING:
     from zarr.core.buffer import BufferPrototype
-    from zarr.core.common import AccessModeLiteral
 
 
 def _dereference_path(root: str, path: str) -> str:
@@ -46,6 +45,62 @@ class StorePath:
         self.store = store
         self.path = path
 
+    @property
+    def read_only(self) -> bool:
+        return self.store.read_only
+
+    @classmethod
+    async def open(
+        cls, store: Store, path: str, mode: AccessModeLiteral | None = None
+    ) -> StorePath:
+        """
+        Open StorePath based on the provided mode.
+
+        * If the mode is 'w-' and the StorePath contains keys, raise a FileExistsError.
+        * If the mode is 'w', delete all keys nested within the StorePath
+        * If the mode is 'a', 'r', or 'r+', do nothing
+
+        Parameters
+        ----------
+        mode : AccessModeLiteral
+            The mode to use when initializing the store path.
+
+        Raises
+        ------
+        FileExistsError
+            If the mode is 'w-' and the store path already exists.
+        """
+
+        await store._ensure_open()
+        self = cls(store, path)
+
+        # fastpath if mode is None
+        if mode is None:
+            return self
+
+        if store.read_only and mode != "r":
+            raise ValueError(f"Store is read-only but mode is '{mode}'")
+
+        match mode:
+            case "w-":
+                if not await self.is_empty():
+                    msg = (
+                        f"{self} is not empty, but `mode` is set to 'w-'."
+                        "Either remove the existing objects in storage,"
+                        "or set `mode` to a value that handles pre-existing objects"
+                        "in storage, like `a` or `w`."
+                    )
+                    raise FileExistsError(msg)
+            case "w":
+                await self.delete_dir()
+            case "a" | "r" | "r+":
+                # No init action
+                pass
+            case _:
+                raise ValueError(f"Invalid mode: {mode}")
+
+        return self
+
     async def get(
         self,
         prototype: BufferPrototype | None = None,
@@ -132,6 +187,17 @@ class StorePath:
         """
         return await self.store.exists(self.path)
 
+    async def is_empty(self) -> bool:
+        """
+        Check if any keys exist in the store with the given prefix.
+
+        Returns
+        -------
+        bool
+            True if no keys exist in the store with the given prefix, False otherwise.
+        """
+        return await self.store.is_empty(self.path)
+
     def __truediv__(self, other: str) -> StorePath:
         """Combine this store path with another path"""
         return self.__class__(self.store, _dereference_path(self.path, other))
@@ -203,7 +269,7 @@ async def make_store_path(
     path : str | None, optional
         The path to use when creating the `StorePath` object.  If None, the
         default path is the empty string.
-    mode : AccessModeLiteral | None, optional
+    mode : StoreAccessMode | None, optional
         The mode to use when creating the `StorePath` object.  If None, the
         default mode is 'r'.
     storage_options : dict[str, Any] | None, optional
@@ -225,45 +291,36 @@ async def make_store_path(
     used_storage_options = False
     path_normalized = normalize_path(path)
     if isinstance(store_like, StorePath):
-        if mode is not None and mode != store_like.store.mode.str:
-            _store = store_like.store.with_mode(mode)
-            await _store._ensure_open()
-            store_like = StorePath(_store, path=store_like.path)
         result = store_like / path_normalized
-    elif isinstance(store_like, Store):
-        if mode is not None and mode != store_like.mode.str:
-            store_like = store_like.with_mode(mode)
-        await store_like._ensure_open()
-        result = StorePath(store_like, path=path_normalized)
-    elif store_like is None:
-        # mode = "w" is an exception to the default mode = 'r'
-        result = StorePath(await MemoryStore.open(mode=mode or "w"), path=path_normalized)
-    elif isinstance(store_like, Path):
-        result = StorePath(
-            await LocalStore.open(root=store_like, mode=mode or "r"), path=path_normalized
-        )
-    elif isinstance(store_like, str):
-        storage_options = storage_options or {}
-
-        if _is_fsspec_uri(store_like):
-            used_storage_options = True
-            result = StorePath(
-                RemoteStore.from_url(store_like, storage_options=storage_options, mode=mode or "r"),
-                path=path_normalized,
-            )
-        else:
-            result = StorePath(
-                await LocalStore.open(root=Path(store_like), mode=mode or "r"), path=path_normalized
-            )
-    elif isinstance(store_like, dict):
-        # We deliberate only consider dict[str, Buffer] here, and not arbitrary mutable mappings.
-        # By only allowing dictionaries, which are in-memory, we know that MemoryStore appropriate.
-        result = StorePath(
-            await MemoryStore.open(store_dict=store_like, mode=mode or "r"), path=path_normalized
-        )
     else:
-        msg = f"Unsupported type for store_like: '{type(store_like).__name__}'"  # type: ignore[unreachable]
-        raise TypeError(msg)
+        assert mode in (None, "r", "r+", "a", "w", "w-")
+        # if mode 'r' was provided, we'll open any new stores as read-only
+        _read_only = mode == "r"
+        if isinstance(store_like, Store):
+            store = store_like
+        elif store_like is None:
+            store = await MemoryStore.open(read_only=_read_only)
+        elif isinstance(store_like, Path):
+            store = await LocalStore.open(root=store_like, read_only=_read_only)
+        elif isinstance(store_like, str):
+            storage_options = storage_options or {}
+
+            if _is_fsspec_uri(store_like):
+                used_storage_options = True
+                store = RemoteStore.from_url(
+                    store_like, storage_options=storage_options, read_only=_read_only
+                )
+            else:
+                store = await LocalStore.open(root=Path(store_like), read_only=_read_only)
+        elif isinstance(store_like, dict):
+            # We deliberate only consider dict[str, Buffer] here, and not arbitrary mutable mappings.
+            # By only allowing dictionaries, which are in-memory, we know that MemoryStore appropriate.
+            store = await MemoryStore.open(store_dict=store_like, read_only=_read_only)
+        else:
+            msg = f"Unsupported type for store_like: '{type(store_like).__name__}'"  # type: ignore[unreachable]
+            raise TypeError(msg)
+
+        result = await StorePath.open(store, path=path_normalized, mode=mode)
 
     if storage_options and not used_storage_options:
         msg = "'storage_options' was provided but unused. 'storage_options' is only used for fsspec filesystem stores."
diff --git a/src/zarr/storage/local.py b/src/zarr/storage/local.py
index a5d0f642..f6e94ae4 100644
--- a/src/zarr/storage/local.py
+++ b/src/zarr/storage/local.py
@@ -2,10 +2,9 @@ from __future__ import annotations
 
 import asyncio
 import io
-import os
 import shutil
 from pathlib import Path
-from typing import TYPE_CHECKING, Self
+from typing import TYPE_CHECKING
 
 from zarr.abc.store import ByteRangeRequest, Store
 from zarr.core.buffer import Buffer
@@ -15,7 +14,6 @@ if TYPE_CHECKING:
     from collections.abc import AsyncIterator, Iterable
 
     from zarr.core.buffer import BufferPrototype
-    from zarr.core.common import AccessModeLiteral
 
 
 def _get(
@@ -74,8 +72,8 @@ class LocalStore(Store):
     ----------
     root : str or Path
         Directory to use as root of store.
-    mode : str
-        Mode in which to open the store. Either 'r', 'r+', 'a', 'w', 'w-'.
+    read_only : bool
+        Whether the store is read-only
 
     Attributes
     ----------
@@ -93,8 +91,8 @@ class LocalStore(Store):
 
     root: Path
 
-    def __init__(self, root: Path | str, *, mode: AccessModeLiteral = "r") -> None:
-        super().__init__(mode=mode)
+    def __init__(self, root: Path | str, *, read_only: bool = False) -> None:
+        super().__init__(read_only=read_only)
         if isinstance(root, str):
             root = Path(root)
         if not isinstance(root, Path):
@@ -104,7 +102,7 @@ class LocalStore(Store):
         self.root = root
 
     async def _open(self) -> None:
-        if not self.mode.readonly:
+        if not self.read_only:
             self.root.mkdir(parents=True, exist_ok=True)
         return await super()._open()
 
@@ -114,23 +112,6 @@ class LocalStore(Store):
         shutil.rmtree(self.root)
         self.root.mkdir()
 
-    async def empty(self) -> bool:
-        # docstring inherited
-        try:
-            with os.scandir(self.root) as it:
-                for entry in it:
-                    if entry.is_file():
-                        # stop once a file is found
-                        return False
-        except FileNotFoundError:
-            return True
-        else:
-            return True
-
-    def with_mode(self, mode: AccessModeLiteral) -> Self:
-        # docstring inherited
-        return type(self)(root=self.root, mode=mode)
-
     def __str__(self) -> str:
         return f"file://{self.root.as_posix()}"
 
diff --git a/src/zarr/storage/logging.py b/src/zarr/storage/logging.py
index e79b7fa4..3b11ddbb 100644
--- a/src/zarr/storage/logging.py
+++ b/src/zarr/storage/logging.py
@@ -5,15 +5,14 @@ import logging
 import time
 from collections import defaultdict
 from contextlib import contextmanager
-from typing import TYPE_CHECKING, Any, Self
+from typing import TYPE_CHECKING, Any
 
-from zarr.abc.store import AccessMode, ByteRangeRequest, Store
+from zarr.abc.store import ByteRangeRequest, Store
 
 if TYPE_CHECKING:
     from collections.abc import AsyncIterator, Generator, Iterable
 
     from zarr.core.buffer import Buffer, BufferPrototype
-    from zarr.core.common import AccessModeLiteral
 
 
 class LoggingStore(Store):
@@ -114,9 +113,9 @@ class LoggingStore(Store):
             return self._store.supports_listing
 
     @property
-    def _mode(self) -> AccessMode:  # type: ignore[override]
+    def read_only(self) -> bool:
         with self.log():
-            return self._store._mode
+            return self._store.read_only
 
     @property
     def _is_open(self) -> bool:
@@ -136,10 +135,10 @@ class LoggingStore(Store):
         with self.log():
             return await self._store._ensure_open()
 
-    async def empty(self) -> bool:
+    async def is_empty(self, prefix: str = "") -> bool:
         # docstring inherited
         with self.log():
-            return await self._store.empty()
+            return await self._store.is_empty(prefix=prefix)
 
     async def clear(self) -> None:
         # docstring inherited
@@ -226,12 +225,3 @@ class LoggingStore(Store):
         # docstring inherited
         with self.log(prefix):
             await self._store.delete_dir(prefix=prefix)
-
-    def with_mode(self, mode: AccessModeLiteral) -> Self:
-        # docstring inherited
-        with self.log(mode):
-            return type(self)(
-                self._store.with_mode(mode),
-                log_level=self.log_level,
-                log_handler=self.log_handler,
-            )
diff --git a/src/zarr/storage/memory.py b/src/zarr/storage/memory.py
index 85abe169..74d77588 100644
--- a/src/zarr/storage/memory.py
+++ b/src/zarr/storage/memory.py
@@ -12,7 +12,6 @@ if TYPE_CHECKING:
     from collections.abc import AsyncIterator, Iterable, MutableMapping
 
     from zarr.core.buffer import BufferPrototype
-    from zarr.core.common import AccessModeLiteral
 
 
 logger = getLogger(__name__)
@@ -26,8 +25,8 @@ class MemoryStore(Store):
     ----------
     store_dict : dict
         Initial data
-    mode : str
-        Access mode
+    read_only : bool
+        Whether the store is read-only
 
     Attributes
     ----------
@@ -48,25 +47,17 @@ class MemoryStore(Store):
         self,
         store_dict: MutableMapping[str, Buffer] | None = None,
         *,
-        mode: AccessModeLiteral = "r",
+        read_only: bool = False,
     ) -> None:
-        super().__init__(mode=mode)
+        super().__init__(read_only=read_only)
         if store_dict is None:
             store_dict = {}
         self._store_dict = store_dict
 
-    async def empty(self) -> bool:
-        # docstring inherited
-        return not self._store_dict
-
     async def clear(self) -> None:
         # docstring inherited
         self._store_dict.clear()
 
-    def with_mode(self, mode: AccessModeLiteral) -> Self:
-        # docstring inherited
-        return type(self)(store_dict=self._store_dict, mode=mode)
-
     def __str__(self) -> str:
         return f"memory://{id(self._store_dict)}"
 
@@ -77,7 +68,7 @@ class MemoryStore(Store):
         return (
             isinstance(other, type(self))
             and self._store_dict == other._store_dict
-            and self.mode == other.mode
+            and self.read_only == other.read_only
         )
 
     async def get(
@@ -194,6 +185,8 @@ class GpuMemoryStore(MemoryStore):
     store_dict : MutableMapping, optional
         A mutable mapping with string keys and :class:`zarr.core.buffer.gpu.Buffer`
         values.
+    read_only : bool
+        Whether to open the store in read-only mode.
     """
 
     _store_dict: MutableMapping[str, gpu.Buffer]  # type: ignore[assignment]
@@ -202,9 +195,9 @@ class GpuMemoryStore(MemoryStore):
         self,
         store_dict: MutableMapping[str, gpu.Buffer] | None = None,
         *,
-        mode: AccessModeLiteral = "r",
+        read_only: bool = False,
     ) -> None:
-        super().__init__(store_dict=store_dict, mode=mode)  # type: ignore[arg-type]
+        super().__init__(store_dict=store_dict, read_only=read_only)  # type: ignore[arg-type]
 
     def __str__(self) -> str:
         return f"gpumemory://{id(self._store_dict)}"
diff --git a/src/zarr/storage/remote.py b/src/zarr/storage/remote.py
index 96280697..c08963bf 100644
--- a/src/zarr/storage/remote.py
+++ b/src/zarr/storage/remote.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import warnings
-from typing import TYPE_CHECKING, Any, Self
+from typing import TYPE_CHECKING, Any
 
 from zarr.abc.store import ByteRangeRequest, Store
 from zarr.storage.common import _dereference_path
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
     from fsspec.asyn import AsyncFileSystem
 
     from zarr.core.buffer import Buffer, BufferPrototype
-    from zarr.core.common import AccessModeLiteral, BytesLike
+    from zarr.core.common import BytesLike
 
 
 ALLOWED_EXCEPTIONS: tuple[type[Exception], ...] = (
@@ -30,8 +30,8 @@ class RemoteStore(Store):
     ----------
     fs : AsyncFileSystem
         The Async FSSpec filesystem to use with this store.
-    mode : AccessModeLiteral
-        The access mode to use.
+    read_only : bool
+        Whether the store is read-only
     path : str
         The root path of the store. This should be a relative path and must not include the
         filesystem scheme.
@@ -77,11 +77,11 @@ class RemoteStore(Store):
     def __init__(
         self,
         fs: AsyncFileSystem,
-        mode: AccessModeLiteral = "r",
+        read_only: bool = False,
         path: str = "/",
         allowed_exceptions: tuple[type[Exception], ...] = ALLOWED_EXCEPTIONS,
     ) -> None:
-        super().__init__(mode=mode)
+        super().__init__(read_only=read_only)
         self.fs = fs
         self.path = path
         self.allowed_exceptions = allowed_exceptions
@@ -102,7 +102,7 @@ class RemoteStore(Store):
     def from_upath(
         cls,
         upath: Any,
-        mode: AccessModeLiteral = "r",
+        read_only: bool = False,
         allowed_exceptions: tuple[type[Exception], ...] = ALLOWED_EXCEPTIONS,
     ) -> RemoteStore:
         """
@@ -112,8 +112,8 @@ class RemoteStore(Store):
         ----------
         upath : UPath
             The upath to the root of the store.
-        mode : str, optional
-            The mode of the store. Defaults to "r".
+        read_only : bool
+            Whether the store is read-only, defaults to False.
         allowed_exceptions : tuple, optional
             The exceptions that are allowed to be raised when accessing the
             store. Defaults to ALLOWED_EXCEPTIONS.
@@ -125,7 +125,7 @@ class RemoteStore(Store):
         return cls(
             fs=upath.fs,
             path=upath.path.rstrip("/"),
-            mode=mode,
+            read_only=read_only,
             allowed_exceptions=allowed_exceptions,
         )
 
@@ -134,7 +134,7 @@ class RemoteStore(Store):
         cls,
         url: str,
         storage_options: dict[str, Any] | None = None,
-        mode: AccessModeLiteral = "r",
+        read_only: bool = False,
         allowed_exceptions: tuple[type[Exception], ...] = ALLOWED_EXCEPTIONS,
     ) -> RemoteStore:
         """
@@ -146,8 +146,8 @@ class RemoteStore(Store):
             The URL to the root of the store.
         storage_options : dict, optional
             The options to pass to fsspec when creating the filesystem.
-        mode : str, optional
-            The mode of the store. Defaults to "r".
+        read_only : bool
+            Whether the store is read-only, defaults to False.
         allowed_exceptions : tuple, optional
             The exceptions that are allowed to be raised when accessing the
             store. Defaults to ALLOWED_EXCEPTIONS.
@@ -173,7 +173,7 @@ class RemoteStore(Store):
             # `not path.startswith("http")` is a special case for the http filesystem (¯\_(ツ)_/¯)
             path = fs._strip_protocol(path)
 
-        return cls(fs=fs, path=path, mode=mode, allowed_exceptions=allowed_exceptions)
+        return cls(fs=fs, path=path, read_only=read_only, allowed_exceptions=allowed_exceptions)
 
     async def clear(self) -> None:
         # docstring inherited
@@ -184,25 +184,6 @@ class RemoteStore(Store):
         except FileNotFoundError:
             pass
 
-    async def empty(self) -> bool:
-        # docstring inherited
-
-        # TODO: it would be nice if we didn't have to list all keys here
-        # it should be possible to stop after the first key is discovered
-        try:
-            return not await self.fs._ls(self.path)
-        except FileNotFoundError:
-            return True
-
-    def with_mode(self, mode: AccessModeLiteral) -> Self:
-        # docstring inherited
-        return type(self)(
-            fs=self.fs,
-            mode=mode,
-            path=self.path,
-            allowed_exceptions=self.allowed_exceptions,
-        )
-
     def __repr__(self) -> str:
         return f"<RemoteStore({type(self.fs).__name__}, {self.path})>"
 
@@ -210,7 +191,7 @@ class RemoteStore(Store):
         return (
             isinstance(other, type(self))
             and self.path == other.path
-            and self.mode == other.mode
+            and self.read_only == other.read_only
             and self.fs == other.fs
         )
 
diff --git a/src/zarr/storage/zip.py b/src/zarr/storage/zip.py
index 968f81cd..755588fb 100644
--- a/src/zarr/storage/zip.py
+++ b/src/zarr/storage/zip.py
@@ -5,7 +5,7 @@ import threading
 import time
 import zipfile
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal, Self
+from typing import TYPE_CHECKING, Any, Literal
 
 from zarr.abc.store import ByteRangeRequest, Store
 from zarr.core.buffer import Buffer, BufferPrototype
@@ -65,10 +65,14 @@ class ZipStore(Store):
         path: Path | str,
         *,
         mode: ZipStoreAccessModeLiteral = "r",
+        read_only: bool | None = None,
         compression: int = zipfile.ZIP_STORED,
         allowZip64: bool = True,
     ) -> None:
-        super().__init__(mode=mode)
+        if read_only is None:
+            read_only = mode == "r"
+
+        super().__init__(read_only=read_only)
 
         if isinstance(path, str):
             path = Path(path)
@@ -121,15 +125,6 @@ class ZipStore(Store):
                 self.path, mode="w", compression=self.compression, allowZip64=self.allowZip64
             )
 
-    async def empty(self) -> bool:
-        # docstring inherited
-        with self._lock:
-            return not self._zf.namelist()
-
-    def with_mode(self, mode: ZipStoreAccessModeLiteral) -> Self:  # type: ignore[override]
-        # docstring inherited
-        raise NotImplementedError("ZipStore cannot be reopened with a new mode.")
-
     def __str__(self) -> str:
         return f"zip://{self.path}"
 
@@ -217,10 +212,19 @@ class ZipStore(Store):
             if key not in members:
                 self._set(key, value)
 
+    async def delete_dir(self, prefix: str) -> None:
+        # only raise NotImplementedError if any keys are found
+        self._check_writable()
+        if not prefix.endswith("/"):
+            prefix += "/"
+        async for _ in self.list_prefix(prefix):
+            raise NotImplementedError
+
     async def delete(self, key: str) -> None:
         # docstring inherited
         # we choose to only raise NotImplementedError here if the key exists
         # this allows the array/group APIs to avoid the overhead of existence checks
+        self._check_writable()
         if await self.exists(key):
             raise NotImplementedError
 


Test patch:
diff --git a/src/zarr/testing/store.py b/src/zarr/testing/store.py
index 3aece0f4..b544bf87 100644
--- a/src/zarr/testing/store.py
+++ b/src/zarr/testing/store.py
@@ -1,11 +1,10 @@
 import pickle
-from typing import Any, Generic, TypeVar, cast
+from typing import Any, Generic, TypeVar
 
 import pytest
 
-from zarr.abc.store import AccessMode, Store
+from zarr.abc.store import Store
 from zarr.core.buffer import Buffer, default_buffer_prototype
-from zarr.core.common import AccessModeLiteral
 from zarr.core.sync import _collect_aiterator
 from zarr.storage._utils import _normalize_interval_index
 from zarr.testing.utils import assert_bytes_equal
@@ -40,7 +39,7 @@ class StoreTests(Generic[S, B]):
 
     @pytest.fixture
     def store_kwargs(self) -> dict[str, Any]:
-        return {"mode": "r+"}
+        return {"read_only": False}
 
     @pytest.fixture
     async def store(self, store_kwargs: dict[str, Any]) -> Store:
@@ -63,27 +62,25 @@ class StoreTests(Generic[S, B]):
         foo = pickle.dumps(store)
         assert pickle.loads(foo) == store
 
-    def test_store_mode(self, store: S, store_kwargs: dict[str, Any]) -> None:
-        assert store.mode == AccessMode.from_literal("r+")
-        assert not store.mode.readonly
+    def test_store_read_only(self, store: S) -> None:
+        assert not store.read_only
 
         with pytest.raises(AttributeError):
-            store.mode = AccessMode.from_literal("w")  # type: ignore[misc]
+            store.read_only = False  # type: ignore[misc]
 
-    @pytest.mark.parametrize("mode", ["r", "r+", "a", "w", "w-"])
-    async def test_store_open_mode(
-        self, store_kwargs: dict[str, Any], mode: AccessModeLiteral
+    @pytest.mark.parametrize("read_only", [True, False])
+    async def test_store_open_read_only(
+        self, store_kwargs: dict[str, Any], read_only: bool
     ) -> None:
-        store_kwargs["mode"] = mode
+        store_kwargs["read_only"] = read_only
         store = await self.store_cls.open(**store_kwargs)
         assert store._is_open
-        assert store.mode == AccessMode.from_literal(mode)
+        assert store.read_only == read_only
 
-    async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None:
-        kwargs = {**store_kwargs, "mode": "r"}
+    async def test_read_only_store_raises(self, store_kwargs: dict[str, Any]) -> None:
+        kwargs = {**store_kwargs, "read_only": True}
         store = await self.store_cls.open(**kwargs)
-        assert store.mode == AccessMode.from_literal("r")
-        assert store.mode.readonly
+        assert store.read_only
 
         # set
         with pytest.raises(ValueError):
@@ -149,7 +146,7 @@ class StoreTests(Generic[S, B]):
         """
         Ensure that data can be written to the store using the store.set method.
         """
-        assert not store.mode.readonly
+        assert not store.read_only
         data_buf = self.buffer_cls.from_bytes(data)
         await store.set(key, data_buf)
         observed = await self.get(store, key)
@@ -233,19 +230,23 @@ class StoreTests(Generic[S, B]):
         assert not await store.exists("foo/zarr.json")
         assert not await store.exists("foo/c/0")
 
-    async def test_empty(self, store: S) -> None:
-        assert await store.empty()
+    async def test_is_empty(self, store: S) -> None:
+        assert await store.is_empty("")
         await self.set(
-            store, "key", self.buffer_cls.from_bytes(bytes("something", encoding="utf-8"))
+            store, "foo/bar", self.buffer_cls.from_bytes(bytes("something", encoding="utf-8"))
         )
-        assert not await store.empty()
+        assert not await store.is_empty("")
+        assert await store.is_empty("fo")
+        assert not await store.is_empty("foo/")
+        assert not await store.is_empty("foo")
+        assert await store.is_empty("spam/")
 
     async def test_clear(self, store: S) -> None:
         await self.set(
             store, "key", self.buffer_cls.from_bytes(bytes("something", encoding="utf-8"))
         )
         await store.clear()
-        assert await store.empty()
+        assert await store.is_empty("")
 
     async def test_list(self, store: S) -> None:
         assert await _collect_aiterator(store.list()) == ()
@@ -302,41 +303,6 @@ class StoreTests(Generic[S, B]):
         keys_observed = await _collect_aiterator(store.list_dir(root + "/"))
         assert sorted(keys_expected) == sorted(keys_observed)
 
-    async def test_with_mode(self, store: S) -> None:
-        data = b"0000"
-        await self.set(store, "key", self.buffer_cls.from_bytes(data))
-        assert (await self.get(store, "key")).to_bytes() == data
-
-        for mode in ["r", "a"]:
-            mode = cast(AccessModeLiteral, mode)
-            clone = store.with_mode(mode)
-            # await store.close()
-            await clone._ensure_open()
-            assert clone.mode == AccessMode.from_literal(mode)
-            assert isinstance(clone, type(store))
-
-            # earlier writes are visible
-            result = await clone.get("key", default_buffer_prototype())
-            assert result is not None
-            assert result.to_bytes() == data
-
-            # writes to original after with_mode is visible
-            await self.set(store, "key-2", self.buffer_cls.from_bytes(data))
-            result = await clone.get("key-2", default_buffer_prototype())
-            assert result is not None
-            assert result.to_bytes() == data
-
-            if mode == "a":
-                # writes to clone is visible in the original
-                await clone.set("key-3", self.buffer_cls.from_bytes(data))
-                result = await clone.get("key-3", default_buffer_prototype())
-                assert result is not None
-                assert result.to_bytes() == data
-
-            else:
-                with pytest.raises(ValueError, match="store mode"):
-                    await clone.set("key-3", self.buffer_cls.from_bytes(data))
-
     async def test_set_if_not_exists(self, store: S) -> None:
         key = "k"
         data_buf = self.buffer_cls.from_bytes(b"0000")
diff --git a/src/zarr/testing/strategies.py b/src/zarr/testing/strategies.py
index 9d254e74..aed5b82e 100644
--- a/src/zarr/testing/strategies.py
+++ b/src/zarr/testing/strategies.py
@@ -6,8 +6,8 @@ import numpy as np
 from hypothesis import given, settings  # noqa: F401
 from hypothesis.strategies import SearchStrategy
 
+import zarr
 from zarr.core.array import Array
-from zarr.core.group import Group
 from zarr.core.sync import sync
 from zarr.storage import MemoryStore, StoreLike
 
@@ -138,7 +138,7 @@ def arrays(
     expected_attrs = {} if attributes is None else attributes
 
     array_path = path + ("/" if not path.endswith("/") else "") + name
-    root = Group.from_store(store, zarr_format=zarr_format)
+    root = zarr.open_group(store, mode="w", zarr_format=zarr_format)
 
     a = root.create_array(
         array_path,
diff --git a/tests/conftest.py b/tests/conftest.py
index 8c66406c..35f31d39 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -28,11 +28,11 @@ async def parse_store(
     store: Literal["local", "memory", "remote", "zip"], path: str
 ) -> LocalStore | MemoryStore | RemoteStore | ZipStore:
     if store == "local":
-        return await LocalStore.open(path, mode="w")
+        return await LocalStore.open(path)
     if store == "memory":
-        return await MemoryStore.open(mode="w")
+        return await MemoryStore.open()
     if store == "remote":
-        return await RemoteStore.open(url=path, mode="w")
+        return await RemoteStore.open(url=path)
     if store == "zip":
         return await ZipStore.open(path + "/zarr.zip", mode="w")
     raise AssertionError
@@ -46,23 +46,23 @@ def path_type(request: pytest.FixtureRequest) -> Any:
 # todo: harmonize this with local_store fixture
 @pytest.fixture
 async def store_path(tmpdir: LEGACY_PATH) -> StorePath:
-    store = await LocalStore.open(str(tmpdir), mode="w")
+    store = await LocalStore.open(str(tmpdir))
     return StorePath(store)
 
 
 @pytest.fixture
 async def local_store(tmpdir: LEGACY_PATH) -> LocalStore:
-    return await LocalStore.open(str(tmpdir), mode="w")
+    return await LocalStore.open(str(tmpdir))
 
 
 @pytest.fixture
 async def remote_store(url: str) -> RemoteStore:
-    return await RemoteStore.open(url, mode="w")
+    return await RemoteStore.open(url)
 
 
 @pytest.fixture
 async def memory_store() -> MemoryStore:
-    return await MemoryStore.open(mode="w")
+    return await MemoryStore.open()
 
 
 @pytest.fixture
diff --git a/tests/test_api.py b/tests/test_api.py
index 7e6ef4ce..fa3d8001 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -72,15 +72,15 @@ async def test_open_array(memory_store: MemoryStore) -> None:
 
     # open array, overwrite
     # store._store_dict = {}
-    store = MemoryStore(mode="w")
+    store = MemoryStore()
     z = open(store=store, shape=200)
     assert isinstance(z, Array)
     assert z.shape == (200,)
 
     # open array, read-only
     store_cls = type(store)
-    ro_store = await store_cls.open(store_dict=store._store_dict, mode="r")
-    z = open(store=ro_store)
+    ro_store = await store_cls.open(store_dict=store._store_dict, read_only=True)
+    z = open(store=ro_store, mode="r")
     assert isinstance(z, Array)
     assert z.shape == (200,)
     assert z.read_only
@@ -106,10 +106,10 @@ async def test_open_group(memory_store: MemoryStore) -> None:
 
     # open group, read-only
     store_cls = type(store)
-    ro_store = await store_cls.open(store_dict=store._store_dict, mode="r")
-    g = open_group(store=ro_store)
+    ro_store = await store_cls.open(store_dict=store._store_dict, read_only=True)
+    g = open_group(store=ro_store, mode="r")
     assert isinstance(g, Group)
-    # assert g.read_only
+    assert g.read_only
 
 
 @pytest.mark.parametrize("zarr_format", [None, 2, 3])
@@ -965,13 +965,13 @@ def test_tree() -> None:
 
 
 def test_open_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with pytest.warns(FutureWarning, match="pass"):
         open(store, "w", shape=(1,))
 
 
 def test_save_array_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with warnings.catch_warnings():
         warnings.filterwarnings(
             "ignore", message="zarr_version is deprecated", category=DeprecationWarning
@@ -987,20 +987,20 @@ def test_save_array_positional_args_deprecated() -> None:
 
 
 def test_group_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with pytest.warns(FutureWarning, match="pass"):
         group(store, True)
 
 
 def test_open_group_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with pytest.warns(FutureWarning, match="pass"):
         open_group(store, "w")
 
 
 def test_open_falls_back_to_open_group() -> None:
     # https://github.com/zarr-developers/zarr-python/issues/2309
-    store = MemoryStore(mode="w")
+    store = MemoryStore()
     zarr.open_group(store, attributes={"key": "value"})
 
     group = zarr.open(store)
@@ -1010,7 +1010,7 @@ def test_open_falls_back_to_open_group() -> None:
 
 async def test_open_falls_back_to_open_group_async() -> None:
     # https://github.com/zarr-developers/zarr-python/issues/2309
-    store = MemoryStore(mode="w")
+    store = MemoryStore()
     await zarr.api.asynchronous.open_group(store, attributes={"key": "value"})
 
     group = await zarr.api.asynchronous.open(store=store)
diff --git a/tests/test_array.py b/tests/test_array.py
index 5f73f2f4..39488961 100644
--- a/tests/test_array.py
+++ b/tests/test_array.py
@@ -196,13 +196,13 @@ def test_array_v3_fill_value(store: MemoryStore, fill_value: int, dtype_str: str
 
 
 def test_create_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with pytest.warns(FutureWarning, match="Pass"):
         Array.create(store, (2, 2), dtype="f8")
 
 
 def test_selection_positional_args_deprecated() -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     arr = Array.create(store, shape=(2, 2), dtype="f8")
 
     with pytest.warns(FutureWarning, match="Pass out"):
@@ -315,7 +315,7 @@ def test_nchunks(test_cls: type[Array] | type[AsyncArray[Any]], nchunks: int) ->
     """
     Test that nchunks returns the number of chunks defined for the array.
     """
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     shape = 100
     arr = Array.create(store, shape=(shape,), chunks=(ceildiv(shape, nchunks),), dtype="i4")
     expected = nchunks
@@ -331,7 +331,7 @@ async def test_nchunks_initialized(test_cls: type[Array] | type[AsyncArray[Any]]
     """
     Test that nchunks_initialized accurately returns the number of stored chunks.
     """
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     arr = Array.create(store, shape=(100,), chunks=(10,), dtype="i4")
 
     # write chunks one at a time
@@ -359,7 +359,7 @@ async def test_chunks_initialized() -> None:
     """
     Test that chunks_initialized accurately returns the keys of stored chunks.
     """
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     arr = Array.create(store, shape=(100,), chunks=(10,), dtype="i4")
 
     chunks_accumulated = tuple(
@@ -373,34 +373,32 @@ async def test_chunks_initialized() -> None:
 
 
 def test_default_fill_values() -> None:
-    a = Array.create(MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="<U4")
+    a = Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="<U4")
     assert a.fill_value == ""
 
-    b = Array.create(MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="<S4")
+    b = Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="<S4")
     assert b.fill_value == b""
 
-    c = Array.create(MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="i")
+    c = Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="i")
     assert c.fill_value == 0
 
-    d = Array.create(MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="f")
+    d = Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="f")
     assert d.fill_value == 0.0
 
 
 def test_vlen_errors() -> None:
     with pytest.raises(ValueError, match="At least one ArrayBytesCodec is required."):
-        Array.create(MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="<U4", codecs=[])
+        Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="<U4", codecs=[])
 
     with pytest.raises(
         ValueError,
         match="For string dtype, ArrayBytesCodec must be `VLenUTF8Codec`, got `BytesCodec`.",
     ):
-        Array.create(
-            MemoryStore({}, mode="w"), shape=5, chunk_shape=5, dtype="<U4", codecs=[BytesCodec()]
-        )
+        Array.create(MemoryStore(), shape=5, chunk_shape=5, dtype="<U4", codecs=[BytesCodec()])
 
     with pytest.raises(ValueError, match="Only one ArrayBytesCodec is allowed."):
         Array.create(
-            MemoryStore({}, mode="w"),
+            MemoryStore(),
             shape=5,
             chunk_shape=5,
             dtype="<U4",
@@ -411,7 +409,7 @@ def test_vlen_errors() -> None:
 @pytest.mark.parametrize("zarr_format", [2, 3])
 def test_update_attrs(zarr_format: int) -> None:
     # regression test for https://github.com/zarr-developers/zarr-python/issues/2328
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     arr = Array.create(store=store, shape=5, chunk_shape=5, dtype="f8", zarr_format=zarr_format)
     arr.attrs["foo"] = "bar"
     assert arr.attrs["foo"] == "bar"
@@ -672,7 +670,7 @@ def test_array_create_order(
     ],
 )
 async def test_special_complex_fill_values_roundtrip(fill_value: Any, expected: list[Any]) -> None:
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     Array.create(store=store, shape=(1,), dtype=np.complex64, fill_value=fill_value)
     content = await store.get("zarr.json", prototype=default_buffer_prototype())
     assert content is not None
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
index 12097eb2..b26db5df 100644
--- a/tests/test_attributes.py
+++ b/tests/test_attributes.py
@@ -4,7 +4,7 @@ import zarr.storage
 
 
 def test_put() -> None:
-    store = zarr.storage.MemoryStore({}, mode="w")
+    store = zarr.storage.MemoryStore()
     attrs = zarr.core.attributes.Attributes(
         zarr.Group.from_store(store, attributes={"a": 1, "b": 2})
     )
@@ -14,7 +14,7 @@ def test_put() -> None:
 
 
 def test_asdict() -> None:
-    store = zarr.storage.MemoryStore({}, mode="w")
+    store = zarr.storage.MemoryStore()
     attrs = zarr.core.attributes.Attributes(
         zarr.Group.from_store(store, attributes={"a": 1, "b": 2})
     )
diff --git a/tests/test_buffer.py b/tests/test_buffer.py
index 60816d76..7a275516 100644
--- a/tests/test_buffer.py
+++ b/tests/test_buffer.py
@@ -48,7 +48,7 @@ async def test_async_array_prototype() -> None:
 
     expect = np.zeros((9, 9), dtype="uint16", order="F")
     a = await AsyncArray.create(
-        StorePath(StoreExpectingTestBuffer(mode="w")) / "test_async_array_prototype",
+        StorePath(StoreExpectingTestBuffer()) / "test_async_array_prototype",
         shape=expect.shape,
         chunk_shape=(5, 5),
         dtype=expect.dtype,
@@ -77,7 +77,7 @@ async def test_async_array_gpu_prototype() -> None:
 
     expect = cp.zeros((9, 9), dtype="uint16", order="F")
     a = await AsyncArray.create(
-        StorePath(MemoryStore(mode="w")) / "test_async_array_gpu_prototype",
+        StorePath(MemoryStore()) / "test_async_array_gpu_prototype",
         shape=expect.shape,
         chunk_shape=(5, 5),
         dtype=expect.dtype,
@@ -99,7 +99,7 @@ async def test_async_array_gpu_prototype() -> None:
 async def test_codecs_use_of_prototype() -> None:
     expect = np.zeros((10, 10), dtype="uint16", order="F")
     a = await AsyncArray.create(
-        StorePath(StoreExpectingTestBuffer(mode="w")) / "test_codecs_use_of_prototype",
+        StorePath(StoreExpectingTestBuffer()) / "test_codecs_use_of_prototype",
         shape=expect.shape,
         chunk_shape=(5, 5),
         dtype=expect.dtype,
@@ -134,7 +134,7 @@ async def test_codecs_use_of_prototype() -> None:
 async def test_codecs_use_of_gpu_prototype() -> None:
     expect = cp.zeros((10, 10), dtype="uint16", order="F")
     a = await AsyncArray.create(
-        StorePath(MemoryStore(mode="w")) / "test_codecs_use_of_gpu_prototype",
+        StorePath(MemoryStore()) / "test_codecs_use_of_gpu_prototype",
         shape=expect.shape,
         chunk_shape=(5, 5),
         dtype=expect.dtype,
diff --git a/tests/test_config.py b/tests/test_config.py
index ddabffb4..2e919a0a 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -201,7 +201,7 @@ def test_config_buffer_implementation() -> None:
     # has default value
     assert fully_qualified_name(get_buffer_class()) == config.defaults[0]["buffer"]
 
-    arr = zeros(shape=(100), store=StoreExpectingTestBuffer(mode="w"))
+    arr = zeros(shape=(100), store=StoreExpectingTestBuffer())
 
     # AssertionError of StoreExpectingTestBuffer when not using my buffer
     with pytest.raises(AssertionError):
@@ -219,7 +219,7 @@ def test_config_buffer_implementation() -> None:
     data2d = np.arange(1000).reshape(100, 10)
     arr_sharding = zeros(
         shape=(100, 10),
-        store=StoreExpectingTestBuffer(mode="w"),
+        store=StoreExpectingTestBuffer(),
         codecs=[ShardingCodec(chunk_shape=(10, 10))],
     )
     arr_sharding[:] = data2d
@@ -227,7 +227,7 @@ def test_config_buffer_implementation() -> None:
 
     arr_Crc32c = zeros(
         shape=(100, 10),
-        store=StoreExpectingTestBuffer(mode="w"),
+        store=StoreExpectingTestBuffer(),
         codecs=[BytesCodec(), Crc32cCodec()],
     )
     arr_Crc32c[:] = data2d
diff --git a/tests/test_group.py b/tests/test_group.py
index 6e185097..afa29020 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -1196,12 +1196,16 @@ async def test_members_name(store: Store, consolidate: bool, zarr_format: ZarrFo
 
 
 async def test_open_mutable_mapping():
-    group = await zarr.api.asynchronous.open_group(store={}, mode="w")
+    group = await zarr.api.asynchronous.open_group(
+        store={},
+    )
     assert isinstance(group.store_path.store, MemoryStore)
 
 
 def test_open_mutable_mapping_sync():
-    group = zarr.open_group(store={}, mode="w")
+    group = zarr.open_group(
+        store={},
+    )
     assert isinstance(group.store_path.store, MemoryStore)
 
 
@@ -1347,7 +1351,7 @@ class TestGroupMetadata:
 
 class TestInfo:
     def test_info(self):
-        store = zarr.storage.MemoryStore(mode="w")
+        store = zarr.storage.MemoryStore()
         A = zarr.group(store=store, path="A")
         B = A.create_group(name="B")
 
@@ -1379,7 +1383,7 @@ class TestInfo:
 def test_update_attrs() -> None:
     # regression test for https://github.com/zarr-developers/zarr-python/issues/2328
     root = Group.from_store(
-        MemoryStore({}, mode="w"),
+        MemoryStore(),
     )
     root.attrs["foo"] = "bar"
     assert root.attrs["foo"] == "bar"
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index 2c51f3da..6f80705d 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
 
 @pytest.fixture
 async def store() -> AsyncGenerator[StorePath]:
-    return StorePath(await MemoryStore.open(mode="w"))
+    return StorePath(await MemoryStore.open())
 
 
 def zarr_array_from_numpy_array(
@@ -62,7 +62,7 @@ class CountingDict(MemoryStore):
 
     @classmethod
     async def open(cls) -> CountingDict:
-        store = await super().open(mode="w")
+        store = await super().open()
         store.counter = Counter()
         return store
 
diff --git a/tests/test_metadata/test_consolidated.py b/tests/test_metadata/test_consolidated.py
index d9143d09..85e32f5d 100644
--- a/tests/test_metadata/test_consolidated.py
+++ b/tests/test_metadata/test_consolidated.py
@@ -281,7 +281,7 @@ class TestConsolidated:
 
     async def test_not_writable_raises(self, memory_store: zarr.storage.MemoryStore) -> None:
         await group(store=memory_store, attributes={"foo": "bar"})
-        read_store = zarr.storage.MemoryStore(store_dict=memory_store._store_dict)
+        read_store = zarr.storage.MemoryStore(store_dict=memory_store._store_dict, read_only=True)
         with pytest.raises(ValueError, match="does not support writing"):
             await consolidate_metadata(read_store)
 
@@ -457,7 +457,7 @@ class TestConsolidated:
 
     @pytest.mark.parametrize("zarr_format", [2, 3])
     async def test_open_consolidated_raises_async(self, zarr_format: ZarrFormat):
-        store = zarr.storage.MemoryStore(mode="w")
+        store = zarr.storage.MemoryStore()
         await AsyncGroup.from_store(store, zarr_format=zarr_format)
         with pytest.raises(ValueError):
             await zarr.api.asynchronous.open_consolidated(store, zarr_format=zarr_format)
@@ -466,7 +466,7 @@ class TestConsolidated:
             await zarr.api.asynchronous.open_consolidated(store, zarr_format=None)
 
     async def test_consolidated_metadata_v2(self):
-        store = zarr.storage.MemoryStore(mode="w")
+        store = zarr.storage.MemoryStore()
         g = await AsyncGroup.from_store(store, attributes={"key": "root"}, zarr_format=2)
         await g.create_array(name="a", shape=(1,), attributes={"key": "a"})
         g1 = await g.create_group(name="g1", attributes={"key": "g1"})
diff --git a/tests/test_metadata/test_v2.py b/tests/test_metadata/test_v2.py
index 089d5c98..003aef33 100644
--- a/tests/test_metadata/test_v2.py
+++ b/tests/test_metadata/test_v2.py
@@ -141,7 +141,7 @@ class TestConsolidated:
             "zarr_consolidated_format": 1,
         }
         store_dict = {}
-        store = zarr.storage.MemoryStore(store_dict=store_dict, mode="a")
+        store = zarr.storage.MemoryStore(store_dict=store_dict)
         await store.set(
             ".zattrs", cpu.Buffer.from_bytes(json.dumps({"Conventions": "COARDS"}).encode())
         )
diff --git a/tests/test_store/test_core.py b/tests/test_store/test_core.py
index 38292e23..4d3f305e 100644
--- a/tests/test_store/test_core.py
+++ b/tests/test_store/test_core.py
@@ -1,11 +1,11 @@
 import tempfile
 from pathlib import Path
-from typing import Literal
 
 import pytest
 from _pytest.compat import LEGACY_PATH
 from upath import UPath
 
+from zarr.core.common import AccessModeLiteral
 from zarr.storage._utils import normalize_path
 from zarr.storage.common import StoreLike, StorePath, make_store_path
 from zarr.storage.local import LocalStore
@@ -24,13 +24,13 @@ async def test_make_store_path_none(path: str) -> None:
 
 
 @pytest.mark.parametrize("path", [None, "", "bar"])
-@pytest.mark.parametrize("store_type", [str, Path, LocalStore])
-@pytest.mark.parametrize("mode", ["r", "w", "a"])
+@pytest.mark.parametrize("store_type", [str, Path])
+@pytest.mark.parametrize("mode", ["r", "w"])
 async def test_make_store_path_local(
     tmpdir: LEGACY_PATH,
     store_type: type[str] | type[Path] | type[LocalStore],
     path: str,
-    mode: Literal["r", "w", "a"],
+    mode: AccessModeLiteral,
 ) -> None:
     """
     Test the various ways of invoking make_store_path that create a LocalStore
@@ -40,26 +40,27 @@ async def test_make_store_path_local(
     assert isinstance(store_path.store, LocalStore)
     assert Path(store_path.store.root) == Path(tmpdir)
     assert store_path.path == normalize_path(path)
-    assert store_path.store.mode.str == mode
+    assert store_path.read_only == (mode == "r")
 
 
 @pytest.mark.parametrize("path", [None, "", "bar"])
-@pytest.mark.parametrize("mode", ["r", "w", "a"])
+@pytest.mark.parametrize("mode", ["r", "w"])
 async def test_make_store_path_store_path(
-    tmpdir: LEGACY_PATH, path: str, mode: Literal["r", "w", "a"]
+    tmpdir: LEGACY_PATH, path: str, mode: AccessModeLiteral
 ) -> None:
     """
     Test invoking make_store_path when the input is another store_path. In particular we want to ensure
     that a new path is handled correctly.
     """
-    store_like = StorePath(LocalStore(str(tmpdir)), path="root")
+    ro = mode == "r"
+    store_like = await StorePath.open(LocalStore(str(tmpdir), read_only=ro), path="root", mode=mode)
     store_path = await make_store_path(store_like, path=path, mode=mode)
     assert isinstance(store_path.store, LocalStore)
     assert Path(store_path.store.root) == Path(tmpdir)
     path_normalized = normalize_path(path)
     assert store_path.path == (store_like / path_normalized).path
 
-    assert store_path.store.mode.str == mode
+    assert store_path.read_only == ro
 
 
 async def test_make_store_path_invalid() -> None:
@@ -79,10 +80,10 @@ async def test_make_store_path_fsspec(monkeypatch) -> None:
     "store_like",
     [
         None,
-        str(tempfile.TemporaryDirectory()),
+        tempfile.TemporaryDirectory().name,
         Path(tempfile.TemporaryDirectory().name),
-        StorePath(store=MemoryStore(store_dict={}, mode="w"), path="/"),
-        MemoryStore(store_dict={}, mode="w"),
+        StorePath(store=MemoryStore(store_dict={}), path="/"),
+        MemoryStore(store_dict={}),
         {},
     ],
 )
diff --git a/tests/test_store/test_local.py b/tests/test_store/test_local.py
index 239364a0..c614d32c 100644
--- a/tests/test_store/test_local.py
+++ b/tests/test_store/test_local.py
@@ -28,7 +28,7 @@ class TestLocalStore(StoreTests[LocalStore, cpu.Buffer]):
 
     @pytest.fixture
     def store_kwargs(self, tmpdir) -> dict[str, str]:
-        return {"root": str(tmpdir), "mode": "r+"}
+        return {"root": str(tmpdir)}
 
     def test_store_repr(self, store: LocalStore) -> None:
         assert str(store) == f"file://{store.root.as_posix()}"
@@ -43,13 +43,13 @@ class TestLocalStore(StoreTests[LocalStore, cpu.Buffer]):
         assert store.supports_listing
 
     async def test_empty_with_empty_subdir(self, store: LocalStore) -> None:
-        assert await store.empty()
+        assert await store.is_empty("")
         (store.root / "foo/bar").mkdir(parents=True)
-        assert await store.empty()
+        assert await store.is_empty("")
 
     def test_creates_new_directory(self, tmp_path: pathlib.Path):
         target = tmp_path.joinpath("a", "b", "c")
         assert not target.exists()
 
-        store = self.store_cls(root=target, mode="w")
+        store = self.store_cls(root=target)
         zarr.group(store=store)
diff --git a/tests/test_store/test_logging.py b/tests/test_store/test_logging.py
index 50db5c1c..aed1f06f 100644
--- a/tests/test_store/test_logging.py
+++ b/tests/test_store/test_logging.py
@@ -54,10 +54,3 @@ async def test_logging_store_counter(store: Store) -> None:
     else:
         assert wrapped.counter["get"] == 1
         assert wrapped.counter["delete_dir"] == 0
-
-
-async def test_with_mode():
-    wrapped = LoggingStore(store=zarr.storage.MemoryStore(mode="w"), log_level="INFO")
-    new = wrapped.with_mode(mode="r")
-    assert new.mode.str == "r"
-    assert new.log_level == "INFO"
diff --git a/tests/test_store/test_memory.py b/tests/test_store/test_memory.py
index bcd9fc44..56ccb4d3 100644
--- a/tests/test_store/test_memory.py
+++ b/tests/test_store/test_memory.py
@@ -22,7 +22,7 @@ class TestMemoryStore(StoreTests[MemoryStore, cpu.Buffer]):
     def store_kwargs(
         self, request: pytest.FixtureRequest
     ) -> dict[str, str | None | dict[str, Buffer]]:
-        kwargs = {"store_dict": None, "mode": "r+"}
+        kwargs = {"store_dict": None}
         if request.param is True:
             kwargs["store_dict"] = {}
         return kwargs
@@ -62,7 +62,7 @@ class TestGpuMemoryStore(StoreTests[GpuMemoryStore, gpu.Buffer]):
     def store_kwargs(
         self, request: pytest.FixtureRequest
     ) -> dict[str, str | None | dict[str, Buffer]]:
-        kwargs = {"store_dict": None, "mode": "r+"}
+        kwargs = {"store_dict": None}
         if request.param is True:
             kwargs["store_dict"] = {}
         return kwargs
diff --git a/tests/test_store/test_remote.py b/tests/test_store/test_remote.py
index 2ad1fd78..aee62079 100644
--- a/tests/test_store/test_remote.py
+++ b/tests/test_store/test_remote.py
@@ -87,7 +87,6 @@ def s3(s3_base: None) -> Generator[s3fs.S3FileSystem, None, None]:
 async def test_basic() -> None:
     store = RemoteStore.from_url(
         f"s3://{test_bucket_name}/foo/spam/",
-        mode="w",
         storage_options={"endpoint_url": endpoint_url, "anon": False},
     )
     assert store.fs.asynchronous
@@ -113,7 +112,7 @@ class TestRemoteStoreS3(StoreTests[RemoteStore, cpu.Buffer]):
         fs, path = fsspec.url_to_fs(
             f"s3://{test_bucket_name}", endpoint_url=endpoint_url, anon=False, asynchronous=True
         )
-        return {"fs": fs, "path": path, "mode": "r+"}
+        return {"fs": fs, "path": path}
 
     @pytest.fixture
     def store(self, store_kwargs: dict[str, str | bool]) -> RemoteStore:
@@ -206,13 +205,12 @@ class TestRemoteStoreS3(StoreTests[RemoteStore, cpu.Buffer]):
         fs, path = fsspec.url_to_fs(
             f"s3://{test_bucket_name}", endpoint_url=endpoint_url, anon=False, asynchronous=False
         )
-        store_kwargs = {"fs": fs, "path": path, "mode": "r+"}
+        store_kwargs = {"fs": fs, "path": path}
         with pytest.warns(UserWarning, match=r".* was not created with `asynchronous=True`.*"):
             self.store_cls(**store_kwargs)
 
     async def test_empty_nonexistent_path(self, store_kwargs) -> None:
         # regression test for https://github.com/zarr-developers/zarr-python/pull/2343
-        store_kwargs["mode"] = "w-"
         store_kwargs["path"] += "/abc"
         store = await self.store_cls.open(**store_kwargs)
-        assert await store.empty()
+        assert await store.is_empty("")
diff --git a/tests/test_store/test_stateful_hierarchy.py b/tests/test_store/test_stateful_hierarchy.py
index 40c8f449..844e1227 100644
--- a/tests/test_store/test_stateful_hierarchy.py
+++ b/tests/test_store/test_stateful_hierarchy.py
@@ -43,7 +43,7 @@ class ZarrHierarchyStateMachine(SyncMixin, RuleBasedStateMachine):
 
         self.store = store
 
-        self.model = MemoryStore(mode="w")
+        self.model = MemoryStore()
         zarr.group(store=self.model)
 
         # Track state of the hierarchy, these should contain fully qualified paths
diff --git a/tests/test_store/test_stateful_store.py b/tests/test_store/test_stateful_store.py
index 9ac3bbc3..751c1ac7 100644
--- a/tests/test_store/test_stateful_store.py
+++ b/tests/test_store/test_stateful_store.py
@@ -14,7 +14,7 @@ from hypothesis.stateful import (
 from hypothesis.strategies import DataObject
 
 import zarr
-from zarr.abc.store import AccessMode, Store
+from zarr.abc.store import Store
 from zarr.core.buffer import BufferPrototype, cpu, default_buffer_prototype
 from zarr.storage import LocalStore, ZipStore
 from zarr.testing.strategies import key_ranges
@@ -35,8 +35,8 @@ class SyncStoreWrapper(zarr.core.sync.SyncMixin):
         self.store = store
 
     @property
-    def mode(self) -> AccessMode:
-        return self.store.mode
+    def read_only(self) -> bool:
+        return self.store.read_only
 
     def set(self, key: str, data_buffer: zarr.core.buffer.Buffer) -> None:
         return self._sync(self.store.set(key, data_buffer))
@@ -55,8 +55,8 @@ class SyncStoreWrapper(zarr.core.sync.SyncMixin):
     def delete(self, path: str) -> None:
         return self._sync(self.store.delete(path))
 
-    def empty(self) -> bool:
-        return self._sync(self.store.empty())
+    def is_empty(self, prefix: str) -> bool:
+        return self._sync(self.store.is_empty(prefix=prefix))
 
     def clear(self) -> None:
         return self._sync(self.store.clear())
@@ -117,7 +117,7 @@ class ZarrStoreStateMachine(RuleBasedStateMachine):
     @rule(key=zarr_keys, data=st.binary(min_size=0, max_size=MAX_BINARY_SIZE))
     def set(self, key: str, data: DataObject) -> None:
         note(f"(set) Setting {key!r} with {data}")
-        assert not self.store.mode.readonly
+        assert not self.store.read_only
         data_buf = cpu.Buffer.from_bytes(data)
         self.store.set(key, data_buf)
         self.model[key] = data_buf
@@ -179,23 +179,23 @@ class ZarrStoreStateMachine(RuleBasedStateMachine):
 
     @rule()
     def clear(self) -> None:
-        assert not self.store.mode.readonly
+        assert not self.store.read_only
         note("(clear)")
         self.store.clear()
         self.model.clear()
 
-        assert self.store.empty()
+        assert self.store.is_empty("")
 
         assert len(self.model.keys()) == len(list(self.store.list())) == 0
 
     @rule()
     # Local store can be non-empty when there are subdirectories but no files
     @precondition(lambda self: not isinstance(self.store.store, LocalStore))
-    def empty(self) -> None:
-        note("(empty)")
+    def is_empty(self) -> None:
+        note("(is_empty)")
 
         # make sure they either both are or both aren't empty (same state)
-        assert self.store.empty() == (not self.model)
+        assert self.store.is_empty("") == (not self.model)
 
     @rule(key=zarr_keys)
     def exists(self, key: str) -> None:
@@ -228,10 +228,10 @@ class ZarrStoreStateMachine(RuleBasedStateMachine):
         keys = list(self.store.list())
 
         if not keys:
-            assert self.store.empty() is True
+            assert self.store.is_empty("") is True
 
         else:
-            assert self.store.empty() is False
+            assert self.store.is_empty("") is False
 
             for key in keys:
                 assert self.store.exists(key) is True
diff --git a/tests/test_store/test_zip.py b/tests/test_store/test_zip.py
index 8f739893..df22b76e 100644
--- a/tests/test_store/test_zip.py
+++ b/tests/test_store/test_zip.py
@@ -2,13 +2,13 @@ from __future__ import annotations
 
 import os
 import tempfile
+import zipfile
 from typing import TYPE_CHECKING
 
 import numpy as np
 import pytest
 
 import zarr
-from zarr.abc.store import AccessMode
 from zarr.core.buffer import Buffer, cpu, default_buffer_prototype
 from zarr.storage.zip import ZipStore
 from zarr.testing.store import StoreTests
@@ -25,8 +25,9 @@ class TestZipStore(StoreTests[ZipStore, cpu.Buffer]):
     def store_kwargs(self, request) -> dict[str, str | bool]:
         fd, temp_path = tempfile.mkstemp()
         os.close(fd)
+        os.unlink(temp_path)
 
-        return {"path": temp_path, "mode": "w"}
+        return {"path": temp_path, "mode": "w", "read_only": False}
 
     async def get(self, store: ZipStore, key: str) -> Buffer:
         return store._get(key, prototype=default_buffer_prototype())
@@ -34,19 +35,18 @@ class TestZipStore(StoreTests[ZipStore, cpu.Buffer]):
     async def set(self, store: ZipStore, key: str, value: Buffer) -> None:
         return store._set(key, value)
 
-    def test_store_mode(self, store: ZipStore, store_kwargs: dict[str, Any]) -> None:
-        assert store.mode == AccessMode.from_literal(store_kwargs["mode"])
-        assert not store.mode.readonly
+    def test_store_read_only(self, store: ZipStore, store_kwargs: dict[str, Any]) -> None:
+        assert not store.read_only
 
-    async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None:
+    async def test_read_only_store_raises(self, store_kwargs: dict[str, Any]) -> None:
         # we need to create the zipfile in write mode before switching to read mode
         store = await self.store_cls.open(**store_kwargs)
         store.close()
 
-        kwargs = {**store_kwargs, "mode": "r"}
+        kwargs = {**store_kwargs, "mode": "a", "read_only": True}
         store = await self.store_cls.open(**kwargs)
-        assert store.mode == AccessMode.from_literal("r")
-        assert store.mode.readonly
+        assert store._zmode == "a"
+        assert store.read_only
 
         # set
         with pytest.raises(ValueError):
@@ -65,7 +65,7 @@ class TestZipStore(StoreTests[ZipStore, cpu.Buffer]):
         assert store.supports_listing
 
     def test_api_integration(self, store: ZipStore) -> None:
-        root = zarr.open_group(store=store)
+        root = zarr.open_group(store=store, mode="a")
 
         data = np.arange(10000, dtype=np.uint16).reshape(100, 100)
         z = root.create_array(
@@ -93,10 +93,21 @@ class TestZipStore(StoreTests[ZipStore, cpu.Buffer]):
 
         store.close()
 
-    async def test_with_mode(self, store: ZipStore) -> None:
-        with pytest.raises(NotImplementedError, match="new mode"):
-            await super().test_with_mode(store)
-
-    @pytest.mark.parametrize("mode", ["a", "w"])
-    async def test_store_open_mode(self, store_kwargs: dict[str, Any], mode: str) -> None:
-        await super().test_store_open_mode(store_kwargs, mode)
+    @pytest.mark.parametrize("read_only", [True, False])
+    async def test_store_open_read_only(
+        self, store_kwargs: dict[str, Any], read_only: bool
+    ) -> None:
+        if read_only == "r":
+            # create an empty zipfile
+            with zipfile.ZipFile(store_kwargs["path"], mode="w"):
+                pass
+
+        await super().test_store_open_read_only(store_kwargs, read_only)
+
+    @pytest.mark.parametrize(("zip_mode", "read_only"), [("w", False), ("a", False), ("x", False)])
+    async def test_zip_open_mode_translation(
+        self, store_kwargs: dict[str, Any], zip_mode: str, read_only: bool
+    ) -> None:
+        kws = {**store_kwargs, "mode": zip_mode}
+        store = await self.store_cls.open(**kws)
+        assert store.read_only == read_only
diff --git a/tests/test_sync.py b/tests/test_sync.py
index bff3837e..02b3b594 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -142,7 +142,7 @@ def test_sync_mixin(sync_loop) -> None:
 
 
 def test_open_positional_args_deprecate():
-    store = MemoryStore({}, mode="w")
+    store = MemoryStore()
     with pytest.warns(FutureWarning, match="pass"):
         zarr.open(store, "w", shape=(1,))
 
diff --git a/tests/test_v2.py b/tests/test_v2.py
index 3dd17848..94aa513c 100644
--- a/tests/test_v2.py
+++ b/tests/test_v2.py
@@ -16,7 +16,7 @@ from zarr.storage import MemoryStore, StorePath
 
 @pytest.fixture
 async def store() -> Iterator[StorePath]:
-    return StorePath(await MemoryStore.open(mode="w"))
+    return StorePath(await MemoryStore.open())
 
 
 def test_simple(store: StorePath) -> None:
@@ -63,7 +63,7 @@ def test_implicit_fill_value(store: StorePath, dtype: str, fill_value: Any) -> N
 
 def test_codec_pipeline() -> None:
     # https://github.com/zarr-developers/zarr-python/issues/2243
-    store = MemoryStore(mode="w")
+    store = MemoryStore()
     array = zarr.create(
         store=store,
         shape=(1,),
@@ -80,7 +80,7 @@ def test_codec_pipeline() -> None:
 
 @pytest.mark.parametrize("dtype", ["|S", "|V"])
 async def test_v2_encode_decode(dtype):
-    store = zarr.storage.MemoryStore(mode="w")
+    store = zarr.storage.MemoryStore()
     g = zarr.group(store=store, zarr_format=2)
     g.create_array(
         name="foo",