Reading Rows: fetch Loop Versus fetchAll, and the Fetch Mode That Comes First
An admin page needs to list the last twenty orders. You already know how to send a query safely — Prepared Statements and Binding covered prepare() and execute(). The question this chapter answers is what you actually get back from a SELECT, and why that answer changes with one attribute you set per connection.
PDO's default fetch mode is PDO::FETCH_BOTH: every row arrives as an array carrying the column names and the 0-based numeric indexes, so each value is stored twice and your template has to pick a key convention on the spot. Fix that once, immediately after connecting:
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
Now $row['email'] works everywhere, nothing is duplicated in memory, and the row's shape matches what you will echo into HTML. FETCH_ASSOC — associative keys only — is the mode interviewers expect you to name when they ask how rows come out of PDO.
Four calls consume a result set, and they answer different questions:
| Call | Returns | Use when |
|---|---|---|
fetch() |
the next row as an array, or false when exhausted |
you iterate row by row or build one object per row |
fetchAll() |
every remaining row as one array (empty array if none) | the result is small and you want all of it at once |
fetchColumn(0) |
the next row's first column, or false |
you need a single scalar, such as one id or a COUNT |
fetchObject('Order') |
the next row as an instance of a class | you map straight into domain objects |
The canonical read loop:
$stmt = $pdo->prepare(
'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
);
$stmt->execute();
while ($row = $stmt->fetch()) {
printf("#%d %s joined %s\n", $row['id'], $row['email'], $row['created_at']);
}
fetch() returns false at the end of the rows, and false is falsy, so the while condition terminates the loop with no separate row-count bookkeeping. When the query matches nothing, the first fetch() returns false and the loop body never runs. That empty case is exactly where fetchAll() and fetch() diverge in type: fetchAll() gives you [], which count() reports as 0, while fetch() gives you false, which you must never hand to count() — on PHP 8 that is a TypeError.
One confusion worth clearing now: execute() on a SELECT returns true — it means the statement ran, not that rows exist. Execution and fetching are two separate phases; the loop above is where rows materialize. Calling fetch() after the loop has exhausted the statement simply returns false again, not an error.
The trap is result size. fetchAll() materializes the entire result set in PHP memory before you touch it: a runaway export returning 500,000 rows will allocate hundreds of megabytes before your first line of output. The fix is not a bigger memory limit — it is staying on fetch() (or paging with LIMIT/OFFSET and calling closeCursor() between batches) so only one row lives in memory at a time. For a twenty-row admin list, fetchAll() is the right simplification; for anything that can grow without a bound you control, the fetch() loop is the safe default.
One more thing rowCount() will not do for you: on most drivers it is not a trustworthy row count for SELECT. If you need a number — pagination totals, cart badges — write SELECT COUNT(*) and read it through fetchColumn(). The write-path chapters on Transactions and Concurrency and the join patterns ahead both build on these read habits, so make the fetch-mode decision automatic now.
Insert a Row and Get Its Id Back: the lastInsertId() Handshake
A signup handler inserts a users row, then needs the generated id for three follow-ups: a second profiles row keyed by user_id, a welcome-email job carrying the id, and a redirect to /users/42. The insert is one prepared statement; getting the id back correctly is a second skill, and it is the single most common PDO question in real interviews.
$stmt = $pdo->prepare('INSERT INTO users (email, display_name) VALUES (?, ?)');
$stmt->execute([$email, $displayName]);
$userId = $pdo->lastInsertId(); // '42' as a string
$pdo->prepare('INSERT INTO profiles (user_id, bio) VALUES (?, ?)')
->execute([$userId, '']);
Read the semantics precisely, because every part of that snippet is load-bearing:
lastInsertId()lives on the connection, not the statement. It returns the value of theAUTO_INCREMENTcolumn generated by the most recent INSERT on that$pdohandle. If any other INSERT runs on the connection between yours and the call — a logging insert, a helper method, a queued flush — you get their id and silently write profile rows against the wrong user. Keep the INSERT and thelastInsertId()call adjacent, with nothing in between.- It returns a string.
'42'compares fine in PHP and binds fine as an integer parameter, but=== 42is false. Cast if you care:(int) $pdo->lastInsertId(). - The optional argument names the sequence.
lastInsertId('users')is meaningful on databases that use named sequences; on MySQL with a single auto-increment column per table it is redundant. - Driver differences are fair game in interviews. PostgreSQL has no
AUTO_INCREMENT; the idiom there isINSERT ... RETURNING idexecuted withquery(), then read withfetch(PDO::FETCH_ASSOC). SQLite and MySQL both supportlastInsertId(). Knowing the RETURNING variant signals you have written PDO against more than one engine.
What about verifying the insert itself? execute() returns a bool — false means the statement failed (with ERRMODE_EXCEPTION you get a PDOException instead, which is the configuration you want from Connecting with PDO). rowCount() for an INSERT should be 1, but the id handshake is the stronger proof: if lastInsertId() returns '0' immediately after a statement you believe succeeded, something is wrong with the table definition or the connection, and you want to find that out in the handler, not in the redirect.
The failure you will actually hit in a signup handler is the duplicate key. With a unique index on users.email, a second registration with the same address raises PDOException with driver code 1062 (MySQL) — SQLSTATE[23000]: Integrity constraint violation. That error is not a crash to swallow; it is the signal to branch: catch it and tell the user the address is already registered, or make the insert idempotent with INSERT ... ON DUPLICATE KEY UPDATE so a retry updates the timestamp instead of failing. Either way, distinguish "insert failed" from "insert succeeded twice" by reading the exception, never by assuming success.
The race condition is worth internalizing because it scales badly. This naive pattern breaks under any concurrency:
// BROKEN under concurrency: another request's insert can slip in between
$pdo->prepare('INSERT INTO users (email) VALUES (?)')->execute([$email]);
// ... an unrelated INSERT happens here on a reused connection ...
$id = $pdo->lastInsertId(); // not your row's id anymore
Call lastInsertId() on the same $pdo handle, immediately after the INSERT, before any other statement executes. Treat those two lines as one indivisible unit and you will never attach data to the wrong row again. With reads (SELECT) and inserts both solid, the remaining write operations — UPDATE and DELETE — carry their own reporting trap, which is the next chapter.
UPDATE, DELETE, and the affected-rows Truth You Cannot Skip
A profile-edit handler runs UPDATE users SET display_name = ? WHERE id = ?, then tells the user "saved". Weeks later a bug report says edits to a typo'd id "succeed" against nothing. Both failures come from the same habit: executing a write and never checking what it actually did. PDO reports that through rowCount().
$stmt = $pdo->prepare('UPDATE users SET display_name = ? WHERE id = ?');
$stmt->execute([$displayName, $userId]);
if ($stmt->rowCount() === 0) {
// The row was already gone, or nothing changed. Do not report success.
}
rowCount() returns the number of rows the statement affected, which for UPDATE and DELETE means rows it matched and wrote. That number carries real information:
| Situation | rowCount() |
What to do |
|---|---|---|
| Update applied to the expected row | 1 |
report success |
| Id does not exist (deleted by someone else) | 0 |
treat as "not found" — a 404, not a success |
| You submitted identical values | 0 on MySQL by default |
see the matched-vs-changed trap below |
| DELETE matched three stale rows | 3 |
fine — log it, you bulk-cleaned |
The subtlety that catches working developers is MySQL's counting rule. By default MySQL reports rows changed, not rows matched. Submit a display name identical to the stored one and rowCount() returns 0 even though the row exists and the statement was fine. When "did the row exist?" is the question you are asking, tell PDO to count matched rows instead:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::MYSQL_ATTR_FOUND_ROWS => true, // rowCount() now counts matched rows
]);
Two rules make UPDATE and DELETE safe. First, never issue either without a WHERE clause — a missing predicate turns a single-user edit into a full-table write that takes the lock for every row. Second, treat rowCount() === 0 as a branch you handle, not an error to swallow; it is how optimistic concurrency control works:
// Optimistic lock: only apply if nobody bumped the version since you read it
$stmt = $pdo->prepare(
'UPDATE orders SET status = ?, version = version + 1
WHERE id = ? AND version = ?'
);
$stmt->execute([$status, $orderId, $readVersion]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Order changed since you loaded it — reload and retry');
}
That pattern is how web apps avoid last-write-wins corruption without holding locks while users stare at forms. Note the arithmetic in the SET clause — version = version + 1 runs server-side, which is the same discipline Transactions and Concurrency enforces for balances and counters.
DELETE carries the same reporting duty, just in the other direction. A cleanup handler that removes stale sessions should read its own rowCount(): 0 means the filter matched nothing worth logging, 37 tells the audit log exactly how many rows left the table. Batching helps here — DELETE FROM sessions WHERE last_seen < ? is one statement for any number of rows, and rowCount() reports the total. The failure mode to never write is the loop of single-row DELETEs: N round trips where one statement would do, each one an independent chance to fail halfway with no transaction around it.
Reliability per statement type, compressed: rowCount() is meaningful for INSERT, UPDATE, and DELETE; it is not a row count for SELECT on most drivers — use SELECT COUNT(*) with fetchColumn() instead. Put the four habits together — associative fetch mode, the fetch() loop, the adjacent lastInsertId() call, and an checked rowCount() — and you have the full CRUD contract PDO expects you to honor on every write.
Quiz
1. You create a PDO connection with no attribute overrides, run a SELECT, and inside `while ($row = $stmt->fetch())` you read `$row['email']`. What is true of $row?
2. After `$stmt->execute()` of an INSERT into an AUTO_INCREMENT table, which approach reliably gives you the new row's id?
3. On MySQL with default PDO options, you UPDATE a user's row passing exactly the values it already stores. What does rowCount() return?
4. What does fetchAll() return when the SELECT's WHERE clause matches zero rows?
Flashcards
Question
What is PDO's default fetch mode, and how do you change it for every query on a connection?
Click to reveal answer
Answer
FETCH_BOTH — each row carries string column keys and 0-based numeric indexes, duplicating every value. Change it once per connection with setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC) so rows carry column names only.
Question
How do fetch() and fetchAll() differ, including on an empty result set?
Click to reveal answer
Answer
fetch() returns the next row or false once exhausted — on zero rows the first call returns false and a while loop never runs. fetchAll() returns the whole result set as one array, an empty array on zero rows. Use fetch() when the set may be large, fetchAll() when it is bounded (a paginated list).
Question
What does lastInsertId() return, and what is the classic mistake when calling it?
Click to reveal answer
Answer
The value of the AUTO_INCREMENT column generated by the most recent INSERT on that connection (returned as a string). The classic mistake is calling it on a connection where another INSERT ran in between — you then get the wrong row's id. Call it immediately after execute(), on the same handle; on PostgreSQL prefer INSERT ... RETURNING id.
Question
Can you rely on rowCount() to count the rows of a SELECT?
Click to reveal answer
Answer
No — on most drivers rowCount() is not defined for SELECT statements and may report 0 regardless of matches. To count rows, run SELECT COUNT(*) and read it with fetchColumn(). rowCount() is meaningful for INSERT, UPDATE, and DELETE.
Question
Why can an UPDATE that clearly matched a row report rowCount() of 0 on MySQL, and how do you fix it?
Click to reveal answer
Answer
MySQL counts rows changed by default, not rows matched — re-saving identical values modifies nothing, so rowCount() is 0 even though the row exists. Fix: create the connection with PDO::MYSQL_ATTR_FOUND_ROWS => true so rowCount() reports matched rows instead.
Revision Notes
Key Takeaways
- 1. FETCH_BOTH is PDO's default and stores every column twice; set FETCH_ASSOC as the connection default so row shape and memory are both sane
- 2. fetch() streams one row at a time and returns false at the end; fetchAll() materializes everything — right for a paginated list, wrong for an unbounded export
- 3. lastInsertId() reads the AUTO_INCREMENT value from the most recent INSERT on the same connection — call it immediately, or use INSERT ... RETURNING on PostgreSQL
- 4. rowCount() is the affected-row count for INSERT/UPDATE/DELETE but is not a SELECT row count — use COUNT(*) with fetchColumn() for totals
- 5. A 0 from UPDATE/DELETE is information: the row was gone, nothing changed (MySQL counts changed rows), or someone else won the optimistic-lock race — handle it instead of reporting success
Interview Tips
- • "How do you get the id of the row you just inserted?" — lastInsertId() on the same $pdo handle immediately after execute(), returned as a string; add that PostgreSQL uses INSERT ... RETURNING id and that SELECT MAX(id) races with concurrent inserts
- • "An UPDATE returned 0 affected rows — what happened?" — walk the branches: wrong id, identical values under MySQL's changed-rows counting, or an optimistic-lock version mismatch; mention PDO::MYSQL_ATTR_FOUND_ROWS to distinguish 'matched' from 'changed'
- • "Can you use rowCount() for a SELECT's row count?" — no, it is undefined for SELECT on most drivers; answer with SELECT COUNT(*) plus fetchColumn(), and show you know fetch() versus fetchAll() trade-offs by size
- • "Show me an optimistic concurrency check in PDO" — UPDATE ... WHERE id = ? AND version = ?, then branch on rowCount() === 0 to reject the stale write; it proves you understand write conflicts without locks
Cheat Sheet
Performing CRUD Queries (PDO)
Reads:
| Need | Call | Notes |
|---|---|---|
| Iterate rows | fetch() in a while loop |
returns false at the end; never count(false) |
| Small full result | fetchAll() |
empty array when no rows match |
| One scalar / COUNT | fetchColumn(0) |
id or SELECT COUNT(*) totals |
| Instances | fetchObject('Class') |
maps straight to objects |
Set PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC once per connection (default FETCH_BOTH stores every value twice).
Writes:
// INSERT + generated id — keep these two lines adjacent
$pdo->prepare('INSERT INTO users (email) VALUES (?)')->execute([$email]);
$userId = (int) $pdo->lastInsertId(); // string '42'; same connection, called immediately
// UPDATE/DELETE — always check what happened
$stmt = $pdo->prepare('UPDATE users SET display_name = ? WHERE id = ?');
$stmt->execute([$name, $id]);
if ($stmt->rowCount() === 0) { /* not found, or no change (MySQL) */ }
// Optimistic concurrency
$stmt = $pdo->prepare('UPDATE orders SET status = ?, version = version + 1
WHERE id = ? AND version = ?');
MySQL matched vs changed: default rowCount() counts changed rows; add PDO::MYSQL_ATTR_FOUND_ROWS => true to count matched rows.
Never UPDATE or DELETE without a WHERE clause. Never use rowCount() to count SELECT rows — use SELECT COUNT(*).