Zero-copy GDAL ReadAsArray with the buffer protocol
This page answers one question: reading a large raster block copies it into a fresh NumPy array and doubles peak memory, so how do you hand the native buffer to Python without copying — and attach the ownership that stops it becoming a use-after-free? It sits inside the NumPy ABI and Array Interop in Spatial Extensions section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the two mechanisms, the lifetime rule and the test that catches the mistake.
Context & Root Cause
ReadAsArray copies, deliberately: the array it returns is owned by NumPy and remains valid after the dataset is closed, which is the right default for a general-purpose API. The cost is a second allocation the size of the region read, and for tile-scale processing over large rasters that doubling is the difference between a pipeline that fits in memory and one that does not.
A zero-copy path is straightforward to build and has exactly one hard rule: the array must hold a reference to whatever keeps the memory alive. Without it, closing the dataset frees the buffer while an array still points at it, and the next read of that array returns whatever now occupies the memory — or segfaults. The mechanism for expressing that ownership is the array’s base object, and forgetting it is the single most common defect in hand-written spatial bindings, as NumPy ABI and array interop sets out.
Solution / Fix
This targets NumPy 1.23+, GDAL 3.8+, CPython 3.9–3.13, and an extension built to the abi3 contract.
1. Wrap the native buffer and attach an owner
static void release_block(PyObject *capsule) {
GDALRasterBandH band = PyCapsule_GetPointer(capsule, "gdal.block");
GDALRasterBandUnlockBlock(band); /* or whatever releases your buffer */
}
static PyObject *band_view(PyObject *self, PyObject *args) {
/* ... obtain band, block, rows, cols ... */
npy_intp dims[2] = {rows, cols};
PyObject *arr = PyArray_SimpleNewFromData(2, dims, NPY_FLOAT32, block_data);
if (!arr) return NULL;
PyObject *owner = PyCapsule_New(band, "gdal.block", release_block);
if (!owner) { Py_DECREF(arr); return NULL; }
if (PyArray_SetBaseObject((PyArrayObject *)arr, owner) < 0) { /* steals owner */
Py_DECREF(arr);
return NULL;
}
PyArray_CLEARFLAGS((PyArrayObject *)arr, NPY_ARRAY_WRITEABLE);
return arr;
}
PyArray_SetBaseObject steals the reference to the capsule on success and, on failure, does not — which is why the error path decrements the array and not the capsule.
2. Or expose the buffer protocol and let NumPy wrap it
For an extension that would rather not link NumPy at all, implementing tp_as_buffer on a small holder type gives the same result through a Stable-ABI mechanism:
static int block_getbuffer(PyObject *exporter, Py_buffer *view, int flags) {
BlockHolder *self = (BlockHolder *)exporter;
return PyBuffer_FillInfo(view, exporter, self->data, self->nbytes,
1 /* readonly */, flags);
}
import numpy as np
arr = np.asarray(memoryview(holder)) # no copy; holder keeps the block alive
The exporter’s reference count is incremented by PyBuffer_FillInfo, so the holder — and through it the dataset — stays alive as long as any view exists.
3. Document which functions return which
def read_block(path, band, window):
"""Return an independent copy. Safe after the dataset is closed."""
def view_block(path, band, window):
"""Return a read-only view over native memory.
The returned array keeps the dataset alive until it is released.
"""
Verification
# 1. The view has an owner — the check that distinguishes correct from lucky
python - <<'PY'
import mypkg
a = mypkg.view_block("scene.tif", 1, (0, 0, 512, 512))
assert a.base is not None, "no base object — this array will dangle"
assert not a.flags.writeable, "a view over driver memory should be read-only"
print("ownership ok")
PY
# 2. It survives the dataset going out of scope
python - <<'PY'
import gc, mypkg
def get():
return mypkg.view_block("scene.tif", 1, (0, 0, 512, 512))
a = get(); gc.collect()
print(float(a[0, 0])) # must not crash
PY
# 3. Peak memory is one block, not two
/usr/bin/time -v python -c "
import mypkg
a = mypkg.view_block('scene.tif', 1, (0, 0, 4096, 4096))
print(a.shape)" 2>&1 | grep 'Maximum resident'
# expected: roughly the block size, not double it
The first check belongs in the test suite permanently. It is two lines, it runs in milliseconds, and it catches the one mistake that turns a performance optimisation into an intermittent crash in someone else’s pipeline.
When a View Is Worth It
Zero-copy is not free of cost — it costs a constraint on the caller — so it is worth being clear about when the trade pays.
The last row is the design guidance that matters most. A library whose ordinary read function returns a view has made every caller responsible for a rule they did not ask for, and the failures land as crashes in their code rather than errors in yours. Offering read_block (copy) and view_block (view) as separate functions puts the choice where it belongs and makes the constraint visible at the call site.
The middle rows are worth reading together with the concurrency guidance in thread safety and concurrency in GDAL bindings: a view pins a dataset, and a dataset is a single-threaded object, so a view crossing a thread boundary combines two constraints into one hard-to-debug failure.
What Holds What
The ownership chain in a zero-copy read is longer than it looks, and drawing it once makes the failure modes obvious.
Two consequences follow from the chain. First, a view can keep a whole dataset — and its block cache — alive long after the caller believes it was closed, which is exactly the memory behaviour described in fixing memory leaks in GDAL Python bindings. Second, slicing a view produces another array whose base points at the first, so the chain extends rather than breaks — which is correct, and means a small slice retained from a large read pins the entire dataset.
Pitfalls & Alternatives
Returning the array without a base object. It works in every test that uses the array immediately and fails in any code that keeps it. The two-line assertion in the verification section is the whole defence.
Leaving the view writeable. Writing into a driver’s block cache through an array bypasses every consistency mechanism the driver has. Clear the writeable flag unless you have specifically designed for write-through.
Assuming the block is contiguous. A band with a line stride, or an interleaved multi-band read, is not C-contiguous. Either request a contiguous window from the driver or construct the array with explicit strides; constructing it as contiguous when it is not produces silently wrong data.
Using a view to avoid a copy that was not the problem. Profile first. If peak memory is dominated by the block cache rather than by your arrays, raising or lowering GDAL_CACHEMAX is a simpler intervention with no lifetime consequences at all.
Frequently Asked Questions
Does a view work with masked arrays?
Indirectly: build the mask separately and construct the masked array from the view and the mask. The mask itself is a second array, so a fully zero-copy masked read means two views with two owners — workable, and a good reason to consider whether the copy was really the bottleneck.
Can the caller write through the view?
Only if you leave the array writeable, and doing so writes directly into the driver’s cache, bypassing the consistency mechanisms it relies on. Clear the writeable flag unless you have designed a write-through path deliberately and documented it.
How does this interact with with blocks that close the dataset?
The view keeps the dataset alive through the base object, so the Close() inside the block does not free the memory the array points at — but it may put the dataset into a closed state the driver does not expect to read from. Prefer returning copies from context-managed helpers and reserving views for code that manages the lifetime explicitly.
Is the buffer protocol slower than the NumPy C-API?
No; both hand over a pointer. The difference is in what you get alongside it — dtype machinery and casting from NumPy, shape and strides from the buffer protocol — not in the cost of the hand-off itself.
What happens when the array is sliced?
The slice becomes another array whose base points at the first one, so the chain extends and the underlying buffer stays alive. That is correct behaviour and worth knowing: a small slice kept from a large read pins the whole dataset, which can look like a leak in a long-running process.
Should the function name signal that it returns a view?
Yes. Callers cannot tell from the returned object, and the lifetime rule is theirs to honour, so the name is the only place the constraint is visible at the call site. A separate, explicitly named function beats a keyword argument on a general-purpose reader.
Related
- NumPy ABI and array interop in spatial extensions — the parent guide on both contracts and the copy-versus-view decision.
- Memory management in geospatial extensions — the allocator ownership rules a capsule destructor has to respect.
- Thread safety and concurrency in GDAL bindings — why a view should not cross a thread boundary.