How to Iterate Through a 2D Array in Python (Step-by-Step)

Last month, I had to iterate through a 2D array in Python while building a paid time off app for a client’s 10-person US team. HR tracked requests in a shared Excel sheet, so I replaced it with a local Python app and SQLite database.

The SQLite query returned a list of tuples, which works like a 2D array. I needed a clean way to iterate over every row and its values.

This Python tutorial uses the leave tracker to cover practical ways to loop through rows, columns, and cells in nested lists and NumPy arrays.

Why Iterating Through 2D Data Matters

A 2D array is data arranged in rows and columns, like a spreadsheet. In Python, you usually represent this as a list of lists, where each inner list is one row. Our leave tracker stores each employee’s PTO (paid time off) request as a row with the fields: name, start date, end date, and status.

When you query a SQLite database, Python’s sqlite3 module returns results as a list of tuples, one tuple per row. That’s a 2D structure too. Learning to iterate through it properly means you can display, validate, and report on it correctly every time.

Project Setup

I’m using Python 3.12 on my local machine, no cloud services involved. Here’s the project structure, a single file to keep things beginner-friendly:

leave_tracker/
    app.py
    leave.db

Once the app grows, you can split it into modules like db.py, models.py, and cli.py. For now, one file is easier to follow.

Building the SQLite Table

First, I create the database table that stores leave requests. sqlite3 is Python’s built-in module for working with SQLite, a lightweight file-based database that needs no server setup.

import sqlite3
from datetime import datetime

def create_table():
    conn = sqlite3.connect("leave.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS leave_requests (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            employee_name TEXT NOT NULL,
            start_date TEXT NOT NULL,
            end_date TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'pending'
        )
    """)
    conn.commit()
    conn.close()

This creates a table called leave_requests if it doesn’t already exist. Each row holds an employee name, a start date, an end date, and a status. I store dates as text in YYYY-MM-DD format, since SQLite has no native date type, and this format still sorts correctly.

Adding a Leave Request

Next, I built a function to add a new PTO request. This is where the datetime module comes in, Python’s built-in tool for working with dates and calculating time differences.

def add_leave_request(name, start_date, end_date):
    start = datetime.strptime(start_date, "%Y-%m-%d")
    end = datetime.strptime(end_date, "%Y-%m-%d")
    duration = (end - start).days + 1

    conn = sqlite3.connect("leave.db")
    cursor = conn.cursor()
    cursor.execute(
        "INSERT INTO leave_requests (employee_name, start_date, end_date, status) VALUES (?, ?, ?, ?)",
        (name, start_date, end_date, "pending")
    )
    conn.commit()
    conn.close()
    print(f"{name} requested {duration} day(s) off, from {start_date} to {end_date}.")

I calculate duration by subtracting start_date from end_date, then add one so both the first and last day count. This prompt collects the input from the command line:

def prompt_new_request():
    name = input("Employee name: ")
    start_date = input("Start date (YYYY-MM-DD): ")
    end_date = input("End date (YYYY-MM-DD): ")
    add_leave_request(name, start_date, end_date)

If you want tighter input handling, check out this guide on handling multiple exceptions in Python so bad date formats don’t crash your app.

How to Iterate Through a 2D Array in Python With Loops

Here’s where iteration becomes essential. When I fetch all leave requests, sqlite3 returns a list of tuples, one tuple per row, a 2D array in every practical sense.

def list_requests():
    conn = sqlite3.connect("leave.db")
    cursor = conn.cursor()
    cursor.execute("SELECT employee_name, start_date, end_date, status FROM leave_requests")
    rows = cursor.fetchall()
    conn.close()
    return rows

rows looks something like this once printed:

[
    ("Sarah Johnson", "2026-07-20", "2026-07-24", "pending"),
    ("Mike Chen", "2026-08-01", "2026-08-03", "approved"),
    ("Amanda Torres", "2026-07-15", "2026-07-15", "pending"),
]

Nested Loops: The Basic Approach

A nested loop is a loop inside another loop. The outer loop walks through each row, and the inner loop walks through each value in that row. This is the most direct way to visit every cell in a 2D array.

def print_all_requests(rows):
    for row in rows:
        for value in row:
            print(value, end=" | ")
        print()

This prints every field separated by a bar, then starts a new line per row. It’s simple, but it won’t tell you which column you’re looking at, just the raw value.

Row Iteration With Unpacking

Most of the time, you already know your row structure. Unpacking lets you assign each value in a tuple to its own named variable in one line, which reads far more clearly than indexing.

def display_requests(rows):
    for name, start_date, end_date, status in rows:
        print(f"{name}: {start_date} to {end_date} ({status})")

This is my go-to pattern here. Each row unpacks directly into name, start_date, end_date, and status, so the loop body reads like plain English.

Pro Tip: I used to index rows with row[0], row[1], and similar expressions. Now I unpack each row into descriptive names because mistakes stand out during testing. If the query gains a column, I update the unpacking line at the same time.

Using enumerate for Coordinates

Sometimes you need to know a row’s position, not just its content. enumerate() is a built-in function that pairs each item with its index as you loop.

def display_with_row_numbers(rows):
    for row_number, (name, start_date, end_date, status) in enumerate(rows, start=1):
        print(f"{row_number}. {name} - {status}")

I use this to number requests in a CLI menu, so a manager can type “3” to approve a request instead of typing a full name.

When you need an index or range

Most iterations don’t require manual indexing. But if you need to modify a row in place or compare a row to its neighbor, you need the actual index, not just the value.

def mark_overlapping_as_flagged(rows):
    rows = [list(row) for row in rows]
    for i in range(len(rows) - 1):
        current_end = rows[i][2]
        next_start = rows[i + 1][1]
        if current_end >= next_start:
            rows[i][3] = "flagged"
            rows[i + 1][3] = "flagged"
    return rows

I convert tuples to lists first, since tuples can’t be changed after creation. Then range(len(rows) - 1) compares each row to the one right after it, checking for overlapping PTO dates. This only works because I have direct index access to both rows.

Approving a Leave Request

Approving a request updates its status directly in the database, keyed by its ID. Adding, listing, and approving together form the core CRUD (create, read, update, delete) operations of this HR leave management tool.

def approve_request(request_id):
    conn = sqlite3.connect("leave.db")
    cursor = conn.cursor()
    cursor.execute(
        "UPDATE leave_requests SET status = ? WHERE id = ?",
        ("approved", request_id)
    )
    conn.commit()
    conn.close()
    print(f"Request #{request_id} approved.")

I always use parameterized queries with ? placeholders instead of building SQL strings manually. This basic SQLite database safety habit prevents SQL injection, even if someone types odd characters into a name field. In a real HR tool, you’d also want an access-approval step so only a manager account can call this function.

Rectangular vs Jagged 2D Lists

A rectangular list has the same number of columns in every row, like our leave request table. A jagged list has rows of different lengths, which happens more often than you’d expect.

weekly_hours = [
    [8, 8, 8, 8, 8],
    [8, 8, 8],
    [8, 8, 8, 8, 8, 4],
]
How to Iterate Through a 2D Array in Python

Here, each inner list represents a week, but employees on leave mid-week have shorter rows. Iterating through a jagged list still works with nested loops, but you can’t assume every row has the same length.

for week in weekly_hours:
    total = 0
    for hours in week:
        total += hours
    print(f"Week total: {total}")

This works because the inner loop walks through whatever is in each row, short or long. Trouble starts when you index a fixed column, like week[5], on a row without six entries.

List Comprehensions for Transformation

A list comprehension is a compact way to build a new list by looping through an existing one in a single line. It’s ideal when you want to transform 2D data, not just read it.

approved_names = [row[0] for row in rows if row[3] == "approved"]

This grabs just the names of employees with approved leave, in one readable line. You can nest comprehensions too, for full 2D transformation:

uppercase_grid = [[str(value).upper() for value in row] for row in rows]

I use this pattern to normalize data before displaying it in reports, converting every cell to uppercase text without writing four lines of nested loops. If you want to check the size of your dataset before looping, this guide on checking the length of an array in Python is worth a look.

How to Iterate Through a 2D Array in Python With NumPy

Python’s built-in list is flexible and works with any data type, mixed together in the same row. NumPy is a separate library that supplies the ndarray, a fixed-type, rectangular array built for fast numeric computation.

If I wanted to analyze PTO days taken per employee per month as numbers, NumPy is the better tool.

import numpy as np

pto_days = np.array([
    [3, 0, 5, 2],
    [0, 2, 4, 1],
    [5, 5, 0, 3],
])

for row in pto_days:
    print(row)

for value in np.nditer(pto_days):
    print(value, end=" ")

You can refer to the screenshot below to see the output.

Iterate Through a 2D Array in Python

Looping over a NumPy array with a plain for loop gives you whole rows. np.nditer() is a NumPy function built specifically to iterate through every single element, across all dimensions, in one pass. For a deeper look at building and shaping these structures, see this guide on Python NumPy 2D arrays and this overview of the Python NumPy array basics.

Row and Column Access

With NumPy, you can grab an entire column without writing a loop at all, something native Python lists can’t do directly.

first_column = pto_days[:, 0]
second_row = pto_days[1, :]

This slicing pulls every row’s first value, or every value in the second row, faster than a manual loop. Read more in the guide to Python NumPy indexing.

Performance Tradeoffs

For small datasets, like our 10-person leave tracker, plain Python loops are fast enough and easier to read. NumPy earns its keep once you’re processing thousands of rows or doing math across the whole array, since it runs in compiled code instead of a Python-level loop.

If you have a NumPy array but need a plain list for JSON output or storage, converting is straightforward. See this guide on converting a NumPy array to a list in Python for the syntax. If you’re setting up a fresh 2D array, check how to initialize a 2D array in Python or how to create a 2D array in Python.

For a command-line application like ours, you don’t need NumPy for the leave tracker itself. It becomes useful once you start crunching PTO statistics company-wide. If you want a visual front end instead of the CLI, a Tkinter listbox displays each leave request as a selectable row.

Things to Keep in Mind

  • Shared-row aliasing catches people off guard. If you create a 2D list with [[0] * 4] * 3, all three rows point to the same list in memory, so editing one edits all of them.
  • Jagged rows break fixed-column code. Never assume every row has the same length unless you built the array that way on purpose; check with len(row) first.
  • Mutation during iteration causes skipped or duplicated items. Don’t add or remove rows from a list while a for loop is actively walking through it; loop over a copy instead.
  • NumPy isn’t automatically faster for tiny datasets. The overhead of creating an ndarray can outweigh the benefit unless you’re working with hundreds of rows or more.
  • Validate every date and number before it hits the database. A bad start_date string will crash datetime.strptime, so wrap user input in a try/except block.
  • SQLite queries need parameterized values, not string formatting. Building SQL with f-strings opens the door to injection bugs; always pass values through ? placeholders.

Frequently Asked Questions

What’s the difference between a 2D list and a NumPy array?

A regular Python list can hold mixed types and jagged rows, while a NumPy ndarray requires a single data type and a fixed rectangular shape. NumPy trades some flexibility for major speed gains on numeric work.

How do I loop through both rows and columns at once?

Use a nested loop, an outer loop for rows and an inner loop for each value in that row. If you need the column position too, wrap the inner loop in enumerate().

Can I modify a 2D list while iterating through it?

You can modify individual values safely, but avoid adding or removing entire rows mid-loop. Build a new list with a list comprehension instead if you need to filter rows out.

Why did fetchall() return tuples instead of lists?

Python’s sqlite3 module returns each row as a tuple by default, since tuples are immutable and slightly faster to create. You can convert them to lists with list(row) if you need to change values.

Is a list comprehension always better than a nested loop?

Not always. Comprehensions are great for simple one-line transformations, but a nested loop with clear variable names is often more readable when the logic has multiple steps or conditions.

Do I need NumPy for a beginner Python project like this leave tracker?

No. A beginner Python project working with a handful of rows runs fine with plain lists and loops. Reach for NumPy only when you’re doing heavier numeric analysis across large datasets.

Iterating through a 2D array in Python comes down to picking the right loop for the job, nested loops for full control, unpacking for readability, enumerate for position, and NumPy when the numbers get heavy. For most beginner projects like this leave tracker, plain nested loops with unpacking will cover nearly everything you need. I hope you found this article helpful.

You May Also Like

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

pyython developer roadmap

Aspiring to be a Python developer?

Download a FREE PDF on how to become a Python developer.

Let’s be friends

Be the first to know about sales and special discounts.