Interoperability with Python
NumPy sits on top of Python's memory model, and the interop surface is the buffer protocol: any object that exposes a contiguous block of memory - an array module buffer, a bytearray, a memoryview, a ctypes pointer - can be viewed as an ndarray without copying, and any ndarray can be handed back as a buffer. On top of that sit the plain conversions (tolist/np.array) and the practical conventions (accepting array-likes, iterating, and the JSON round trip). This chapter is the boundary layer between NumPy and the rest of Python.
Converting to and from Python
import numpy as np
a = np.array([1.0, 2.0, 3.0])
py = a.tolist() # [1.0, 2.0, 3.0] - plain Python floats
print(type(py), type(py[0])) # list, float
m = np.arange(6).reshape(2, 3)
nested = m.tolist() # nested lists, one per row
print(nested) # [[0, 1, 2], [3, 4, 5]]
# and back: np.array accepts any nested sequence
back = np.array(nested)
print(np.array_equal(back, m)) # True
# scalars: the item() method extracts the Python value
print(a[0].item(), type(a[0].item())) # 1.0 float
tolist() is the one-way bridge into plain Python - it produces native int/float/list objects, which is what JSON serialization and non-NumPy consumers expect. The round trip np.array(x.tolist()) is a cheap, guaranteed-fresh copy. For a single element, .item() extracts the scalar as a Python value (where a[0] still gives a 0-d array). The cost is real - it walks every element - so reserve tolist for the boundary, not the hot path.
The Buffer Protocol: Zero-Copy Bridges
import array
import numpy as np
# a Python array module buffer viewed as an ndarray - no copy
buf = array.array("d", [1.5, 2.5, 3.5])
v = np.asarray(buf)
print(v, v.base is buf) # the view aliases the buffer
# a memoryview sliced and re-shaped into an array
mv = memoryview(buf)
print(np.asarray(mv[:2])) # first two elements, zero copy
# the reverse: an ndarray exposed as a buffer to non-NumPy code
a = np.arange(5, dtype="int64")
print(bytes(memoryview(a))) # the raw bytes, little-endian int64
The buffer protocol is the zero-copy interop path: np.asarray on an object that supports it (array module, memoryview, ctypes, and most C-extension buffers) returns a view over the same memory instead of a copy. This is how NumPy talks to the standard library, to socket receive buffers, and to ctypes structs without a memcpy. The constraint is layout - the buffer must be contiguous and of a dtype NumPy can interpret, or a copy is made to reshape/retype it.
Accepting Array-Likes
import numpy as np
def dot(a, b):
# accept lists, tuples, arrays, scalars - coerce at the boundary
a = np.asarray(a)
b = np.asarray(b)
return a @ b
print(dot([1, 2, 3], [4, 5, 6])) # 32 - lists in, answer out
print(dot(np.array([1, 2]), (3, 4))) # 11 - mixed array-likes fine
# asarray does not copy an ndarray; array() always copies
x = np.arange(3)
print(np.shares_memory(x, np.asarray(x))) # True - no copy
print(np.shares_memory(x, np.array(x))) # False - fresh buffer
Function boundaries should accept "array-likes" - lists, tuples, scalars, arrays - and coerce with np.asarray, which converts only what is not already an array (no needless copy). Reserve np.array (always copies) for when you need independence. This convention is what lets a NumPy function take a caller's list, an int, or a view and treat them uniformly - the reason the ecosystem's APIs read so cleanly.
Iterating: Slow, but Honest
import numpy as np
a = np.arange(6).reshape(2, 3)
# iterating a 2-D array walks the FIRST axis - rows, not elements
for row in a:
print(row) # [0 1 2] then [3 4 5]
# element iteration is a Python loop - use .flat or .ravel()
for v in a.flat:
pass # all six elements, in C order
# the rule: iterate for control flow, vectorize for computation
# a "loop over rows" that does the same math on each row is a
# reshape + whole-array op in disguise
Iterating an ndarray is a Python-level loop - correct, but it forfeits the C-engine speed, so it belongs in control flow (dispatch on a value, call a non-NumPy function per element), not in computation. Iterating a multi-dimensional array yields slices along the first axis, which surprises the unprepared; .flat gives true element iteration. The recurring optimization: a loop that applies the same operation to every row is a reshape plus a whole-array operation wearing a loop's costume.
The JSON Round Trip
import json
import numpy as np
m = np.array([[1.0, 2.0], [3.0, 4.0]])
# ndarray is not JSON-serializable; tolist() is the bridge
payload = json.dumps({"matrix": m.tolist()})
loaded = json.loads(payload)
back = np.array(loaded["matrix"])
print(np.array_equal(back, m)) # True - the round trip is exact
JSON (and most serializers) want plain Python, so the boundary is tolist out and np.array back. For the float values this is exact (the JSON float text preserves the double); for integer arrays it is exact as well. The only loss is the dtype - a float32 becomes a Python float (float64) in transit - so restore it with an explicit dtype on the way back if the width matters.
Summary
Cross the boundary with the right tool for the job: tolist/np.array for plain-Python and JSON round trips, the buffer protocol (asarray over a memoryview/array/ctypes buffer) for zero-copy bridges, and np.asarray at function boundaries to accept array-likes without copying. Iterate only for control flow - a computational loop is a vectorization waiting to happen - and remember the dtype does not survive a JSON trip, so restore it explicitly when the element width matters.