“Why does the first element live at index 0 and not 1?” is one of those questions beginners are quietly embarrassed to ask, and the answer is genuinely satisfying: it falls straight out of how arrays live in memory. This post is the written deep dive for my 60-second short on exactly this:
Prefer the animated version? Watch it on YouTube and subscribe to @onurthedev for a new visual CS short every week. New here? Start with What is an array? first; this post builds directly on it.
Table of contents
Open Table of contents
The index is not a position, it’s an offset
From the previous post, an array is one contiguous block of memory, and the address of any element is computed with:
address_of(i) = base_address + i * element_size
Now ask: what should i be for the first element? The first element sits exactly at base_address. Its distance from the start is zero. So:
address_of(0) = 1000 + 0 * 4 = 1000 âś… the first element, no adjustment
If arrays started at 1, every single access would need a correction:
address_of(i) = base_address + (i - 1) * element_size ❌ extra work, forever
That’s the whole answer. The index is how many elements you skip from the start, not the ordinal “which one is it.” The first element skips zero elements. Once you internalize index = offset, a lot of things stop being memorization and start being obvious:
arr[0]is the first element because it’s zero steps from the base.- The last element of an n-element array is
arr[n - 1], because it’s n minus one steps in. range(n)in Python andi < nin a TypeScript for-loop produce exactly n iterations, hitting offsets 0 through n minus 1.
Dijkstra and the half-open range
There’s a second, more elegant argument, made famous by Edsger Dijkstra’s 1982 note “Why numbering should start at zero”. Represent “the first n numbers” as a range. Of the four possible bracket styles, the half-open range [0, n) (include the start, exclude the end) wins on every count:
- The length equals
n - 0, the difference of the bounds. No plus-one gymnastics. - Two adjacent ranges
[0, k)and[k, n)butt together perfectly, no gap and no overlap. This is exactly why splitting an array for binary search or merge sort is clean. - An empty range is simply
[k, k), no awkward “end before start.”
Both languages bake this convention in:
list(range(5)) # [0, 1, 2, 3, 4] — 0 included, 5 excluded
nums[1:4] # elements at offsets 1, 2, 3 — slice end is exclusive
len(nums[1:4]) # 3 == 4 - 1, lengths are just subtraction
nums.slice(1, 4); // elements at offsets 1, 2, 3 — end exclusive, same convention
Once you see [start, end) everywhere, off-by-one errors stop being random bad luck and start being violations of one consistent rule.
Every loop pattern you actually need
Iteration is where index-zero knowledge becomes muscle memory. Here’s the practical tour, in both languages.
1. The classic index loop
Use it when you genuinely need the index (writing back into the array, comparing neighbors, stepping in reverse).
nums = [10, 20, 30, 40]
for i in range(len(nums)):
nums[i] = nums[i] * 2
const nums = [10, 20, 30, 40];
for (let i = 0; i < nums.length; i++) {
nums[i] = nums[i] * 2;
}
The three-part TS loop reads like a contract: start at offset 0, continue while i < nums.length (never <=, see the traps below), advance by one.
2. The value loop (your default)
If you don’t need the index, don’t manage one. Less state, fewer bugs.
for num in nums:
print(num)
for (const num of nums) {
console.log(num);
}
One TypeScript warning: for...of iterates values; for...in iterates keys as strings (and can include inherited properties). Using for...in on an array is a classic bug. If you’re about to type for...in on an array, you almost certainly want for...of.
3. Index and value together
for i, num in enumerate(nums):
print(f"offset {i} holds {num}")
for (const [i, num] of nums.entries()) {
console.log(`offset ${i} holds ${num}`);
}
enumerate and .entries() exist precisely so you never write the “manual counter you forgot to increment” bug.
4. Backwards
Walking backwards is the safe way to delete while iterating, since removing an element only shifts the part you’ve already visited.
for i in range(len(nums) - 1, -1, -1): # start at last offset, stop after 0
if nums[i] % 2 == 0:
nums.pop(i)
for (let i = nums.length - 1; i >= 0; i--) {
if (nums[i] % 2 === 0) {
nums.splice(i, 1);
}
}
Note the bounds: the last valid offset is length - 1 (offset, not count), and the loop runs while i >= 0 because 0 is a real, valid index. Both facts are index-zero thinking in action.
5. While loops: when the count isn’t known upfront
for is for “walk this collection”; while is for “repeat until a condition changes.” The canonical array example is binary search, which is also half-open ranges earning their keep:
def binary_search(sorted_nums: list[int], target: int) -> int:
lo, hi = 0, len(sorted_nums) # search space is [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if sorted_nums[mid] < target:
lo = mid + 1
else:
hi = mid
if lo < len(sorted_nums) and sorted_nums[lo] == target:
return lo
return -1
function binarySearch(sortedNums: number[], target: number): number {
let lo = 0;
let hi = sortedNums.length; // [lo, hi)
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sortedNums[mid] < target) lo = mid + 1;
else hi = mid;
}
return sortedNums[lo] === target ? lo : -1;
}
6. Declarative iteration: comprehensions, map, filter, reduce
Once loops are second nature, you’ll often express them as transformations instead:
doubled = [n * 2 for n in nums]
evens = [n for n in nums if n % 2 == 0]
total = sum(nums)
const doubled = nums.map(n => n * 2);
const evens = nums.filter(n => n % 2 === 0);
const total = nums.reduce((acc, n) => acc + n, 0);
These aren’t magic: each one is the value loop from pattern 2 wearing a nicer outfit. Knowing that is what lets you reason about their O(n) cost.
The off-by-one hall of fame
Every one of these comes from forgetting that indexes are offsets in a half-open world:
// 1. The <= trap: reads one past the end.
for (let i = 0; i <= nums.length; i++) { ... } // ❌ nums[nums.length] is undefined
// 2. The "last element" trap:
const last = nums[nums.length]; // ❌ off the end
const lastOk = nums[nums.length - 1]; // âś…
// 3. Deleting while iterating forwards: shifts skip elements.
for (let i = 0; i < nums.length; i++) {
nums.splice(i, 1); // ❌ skips the neighbor of every deleted item
}
Python is more forgiving on syntax but not on logic: range(1, len(nums)) silently skips offset 0, and nums[len(nums)] raises IndexError. When a loop misbehaves, check the three usual suspects: the start offset, the end condition, and whether the collection changed size mid-loop.
Keep going
- 🎬 The 60-second visual version: Why does an array start with index 0?
- 📚 Previous in the series: What is an array?
- đź”” A new visual CS short every week on @onurthedev, each with a deep-dive post here
- đź‘‹ More about me and what I build: ucaronur.com