A NumPy array is a multidimensional array of objects all of the same type. In memory, it is an object which points to a block of memory, keeps track of the type of data stored in that memory, keeps track of how many dimensions there are and how large each one is, and - importantly - the spacing between elements along each axis.
For example, you might have a NumPy array that represents the numbers from zero to nine, stored as 32-bit integers, one right after another, in a single block of memory. (for comparison, each Python integer needs to have some type information stored alongside it). You might also have the array of even numbers from zero to eight, stored in the same block of memory, but with a gap of four bytes (one 32-bit integer) between elements. This is called striding, and it means that you can often create a new array referring to a subset of the elements in an array without copying any data. Such subsets are called views. This is an efficiency gain, obviously, but it also allows modification of selected elements of an array in various ways.
An important constraint on NumPy arrays is that for a given axis, all the elements must be spaced by the same number of bytes in memory. NumPy cannot use double-indirection to access array elements, so indexing modes that would require this must produce copies. This constraint makes it possible for all the inner loops in NumPy’s internals to be written in efficient C code.
NumPy arrays offer a number of other possibilities, including using a memory-mapped disk file as the storage space for an array, and record arrays, where each element can have a custom, compound data type.