diff --git a/src/zerial/numpy.py b/src/zerial/numpy.py
index e5f936e..5431318 100644
--- a/src/zerial/numpy.py
+++ b/src/zerial/numpy.py
@@ -33,8 +33,6 @@ if sys.version_info >= (3, 0):
             return x.decode()
         else:
             return str(x)
-
-    buffer_to_ndarray = np.frombuffer
 else:
     numpy_required_passthrus = DEFAULT_PASSTHRUS
 
@@ -47,14 +45,14 @@ else:
 
     num_to_bytes = lambda obj: memoryview(obj.data)
 
-    buffer_to_ndarray = lambda v, *xs, **kw: np.frombuffer(
-        v.tobytes() if isinstance(v, memoryview) else v,
-        *xs,
-        **kw
-    )
+    tostr = lambda x: x
 
-    def tostr(x):
-        return x
+
+def buffer_to_ndarray(v, *xs, **kw):
+    v = v.tobytes() if isinstance(v, memoryview) else v
+    # We have to copy to get an "owned", writable array
+    # TODO: find a better way around this that isn't as expensive
+    return np.frombuffer(v, *xs, **kw).copy()
 
 
 @attr.s

Numpy arrays coming from serialization are not writable
Looks like `np.frombuffer` keeps the `owndata` flag as `False`, which prevents the array from becoming writable.
diff --git a/tests/test_numpy.py b/tests/test_numpy.py
new file mode 100644
index 0000000..9f46692
--- /dev/null
+++ b/tests/test_numpy.py
@@ -0,0 +1,82 @@
+import pytest
+
+import attr
+import numpy as np
+
+from zerial.numpy import NdarrayEncoder, the_numpy_structurer
+
+
+@pytest.fixture
+def encoder():
+    return NdarrayEncoder()
+
+
+@pytest.fixture
+def npztr():
+    return the_numpy_structurer
+
+
+def test_encoder_barfs_on_refcounted_python_objects_in_array(encoder, npztr):
+    arr = np.array([object()])
+    with pytest.raises(TypeError) as ce:
+        encoder.encode(arr, npztr)
+    assert str(ce.value).startswith("cowardly refusing")
+
+
+@pytest.mark.parametrize('arr', [
+    np.array([]),
+    np.empty((1024, 256, 16), dtype=np.float64),
+    np.random.random((10, 10, 10)),
+    np.random.random((5, 15, 2)).T,
+    # TODO: add more tests here
+])
+def test_encode_decode_stability(arr, encoder, npztr):
+    encoded = encoder.encode(arr, npztr)
+    if isinstance(encoded['%data'], memoryview):
+        # force memory read
+        encoded['%data'] = encoded['%data'].tobytes()
+    decoded = encoder.decode(encoded, npztr)
+    assert (arr == decoded).all()
+
+
+def test_encodes_toplevel_when_given_as_type(npztr, encoder):
+    arr = np.array(range(10), dtype=np.int_)
+    des = npztr.destructure(arr, np.ndarray)
+    enc = encoder.encode(arr, npztr)
+    assert des == enc
+
+
+def test_encodes_toplevel_fails_when_not_given_as_type(npztr, encoder):
+    arr = np.linspace(0, 1)
+    with pytest.raises(attr.exceptions.NotAnAttrsClassError):
+        npztr.destructure(arr)
+
+
+@attr.s(eq=False)
+class Example(object):
+    arr = attr.ib(type=np.ndarray)
+    other = attr.ib(type=str, default='')
+
+    def __eq__(self, other):
+        return (
+            self.__class__ == other.__class__ and
+            self.other == other.other and
+            (self.arr == other.arr).all()
+        )
+
+
+def test_can_encode_structured_ndarray(npztr):
+    example = Example(np.array([1, 19, 18]))
+    destructed = npztr.destructure(example)
+    restructed = npztr.restructure(Example, destructed)
+    assert example == restructed
+
+
+def test_restructured_array_is_writable(npztr):
+    arr = np.array([1, 2], dtype=np.int_)
+    example = Example(arr)
+    destructured = npztr.destructure(example)
+    restructured = npztr.restructure(Example, destructured)
+    new_arr = restructured.arr
+    new_arr += 2
+    assert ((arr + 2) == new_arr).all()

pip install -e .[dev]
parse_log_pytest
pytest
pytest
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
Babel==2.11.0
bump2version==1.0.1
certifi==2021.5.30
charset-normalizer==2.0.12
docutils==0.17.1
flake8==5.0.4
idna==3.10
imagesize==1.4.1
importlib-metadata==4.2.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==3.0.3
MarkupSafe==2.0.1
mccabe==0.7.0
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
numpy==1.19.5
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pycodestyle==2.9.1
pyflakes==2.5.0
Pygments==2.14.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
pytest-mock==3.6.1
pytz==2025.2
requests==2.27.1
snowballstemmer==2.2.0
Sphinx==4.3.2
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
urllib3==1.26.20
-e git+https://github.com/jriddy/zerial.git@30867c7d8f6565c180bb34560a5f3b2393896c8a#egg=zerial
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: zerial
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - babel==2.11.0
      - bump2version==1.0.1
      - charset-normalizer==2.0.12
      - docutils==0.17.1
      - flake8==5.0.4
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.2.0
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - mccabe==0.7.0
      - numpy==1.19.5
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
      - pygments==2.14.0
      - pytest-mock==3.6.1
      - pytz==2025.2
      - requests==2.27.1
      - snowballstemmer==2.2.0
      - sphinx==4.3.2
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - urllib3==1.26.20
prefix: /opt/conda/envs/zerial

tests/test_numpy.py::test_restructured_array_is_writable
tests/test_numpy.py::test_encoder_barfs_on_refcounted_python_objects_in_array tests/test_numpy.py::test_encode_decode_stability[arr0] tests/test_numpy.py::test_encode_decode_stability[arr1] tests/test_numpy.py::test_encode_decode_stability[arr2] tests/test_numpy.py::test_encode_decode_stability[arr3] tests/test_numpy.py::test_encodes_toplevel_when_given_as_type tests/test_numpy.py::test_encodes_toplevel_fails_when_not_given_as_type tests/test_numpy.py::test_can_encode_structured_ndarray
src/zerial/numpy.py
src/zerial/numpy.py