PHP PDO Prepared Statements for Beginners
Learn PHP PDO prepared statements with MySQL: connect safely, insert, select, update and delete, plus LIKE, IN lists, LIMIT, transactions and SQL injection.
To get one row as an associative array with PDO, run a prepared statement and call $stmt->fetch(PDO::FETCH_ASSOC). It returns an array such as ['id' => 1, 'name' => 'Priya Shah'], or false when there’s no matching row. To get every row, call $stmt->fetchAll(PDO::FETCH_ASSOC), which returns a list of those arrays (an empty array if nothing matched). For a single value, such as a COUNT(*), use $stmt->fetchColumn(). Set PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC when you connect and you can drop the argument from every call.
I ran every example below on PHP 8.4.26 with MySQL 8.4.6 on 8 October 2026, with full error reporting on, and pasted the real output under each one. If you’re new to PDO, start with my guide to PHP PDO prepared statements, which covers connecting and binding values safely. This post picks up where that one stops: getting the data back out.
The examples use a tiny shop database with two tables: customers (4 people in Leeds, Cardiff, Belfast and Glasgow) and orders (5 orders with a total, a status and a date).
CREATE TABLE customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL,
city VARCHAR(60) NOT NULL
);
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
customer_id INT UNSIGNED NOT NULL,
total DECIMAL(8,2) NOT NULL,
status ENUM('paid','shipped','refunded') NOT NULL,
placed_on DATE NOT NULL
);
Here’s the connection every example uses. The three options matter. ERRMODE_EXCEPTION makes failed queries throw instead of failing silently. DEFAULT_FETCH_MODE sets associative arrays as the default. Turning off emulated prepares makes MySQL do the real prepare, which is the safer default.
<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
The username and password come from environment variables, so they never end up in your code or in Git.
fetch() returns the next row from a result set. For a query that should match one row, such as looking up a record by its ID, that’s all you need.
$stmt = $pdo->prepare('SELECT id, name, city FROM customers WHERE id = ?');
$stmt->execute([1]);
$customer = $stmt->fetch(PDO::FETCH_ASSOC);
print_r($customer);
$stmt->execute([99]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));
Output:
Array
(
[id] => 1
[name] => Priya Shah
[city] => Leeds
)
bool(false)
The keys are the column names (or aliases) from your SELECT. When no row matches, fetch() returns false, not null and not an empty array. So the usual pattern is:
$customer = $stmt->fetch();
if ($customer === false) {
http_response_code(404);
exit('Customer not found');
}
This is the same pattern you’d use for a login form: fetch the user row by email, check it isn’t false, then check the password hash. The full list of fetch() options is in the PHP manual for PDOStatement::fetch.
If you don’t set a fetch mode, PDO uses PDO::FETCH_BOTH. That gives you every column twice: once by name and once by number.
$stmt = $pdo->prepare('SELECT id, name FROM customers WHERE id = ?');
$stmt->execute([2]);
print_r($stmt->fetch(PDO::FETCH_BOTH));
Output:
Array
(
[id] => 2
[0] => 2
[name] => Tom Evans
[1] => Tom Evans
)
That doubles the memory for big result sets and makes json_encode() output messy. FETCH_ASSOC gives you only the named keys, which is almost always what you want.
fetchAll() returns all remaining rows as an array. With FETCH_ASSOC, that’s a list of associative arrays, the classic “multidimensional array” you can loop over, sort or turn into JSON.
$stmt = $pdo->prepare('SELECT id, total, status FROM orders WHERE status = ? ORDER BY placed_on');
$stmt->execute(['paid']);
$orders = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo count($orders), " paid orders\n";
print_r($orders);
$stmt->execute(['cancelled']);
var_dump($stmt->fetchAll());
Output:
2 paid orders
Array
(
[0] => Array
(
[id] => 2
[total] => 8.50
[status] => paid
)
[1] => Array
(
[id] => 3
[total] => 61.20
[status] => paid
)
)
array(0) {
}
Two things to notice. First, an empty result is an empty array, so if (!$orders) and count($orders) === 0 both work. Second, I reused the same prepared statement with a different value. You only prepare once, then call execute() as often as you like.
When you print these rows into a page, escape every value. My post on htmlspecialchars() and XSS shows the helper I use.
A PDOStatement is iterable, so you can loop over it directly. PHP then works through one row at a time instead of building the whole array first.
$stmt = $pdo->query('SELECT name, city FROM customers ORDER BY name');
foreach ($stmt as $row) {
echo $row['name'], ' (', $row['city'], ")\n";
}
Output:
Aoife Byrne (Belfast)
Callum Reid (Glasgow)
Priya Shah (Leeds)
Tom Evans (Cardiff)
Use fetchAll() when you need the whole list (to count it, sort it or pass it to a template). Use the loop or while ($row = $stmt->fetch()) for big exports, where holding every row in memory would be wasteful.
When a query returns a single value, fetchColumn() saves you from digging it out of an array. It returns the first column of the next row.
$stmt = $pdo->prepare('SELECT COUNT(*) FROM orders WHERE status = ?');
$stmt->execute(['shipped']);
$shipped = $stmt->fetchColumn();
var_dump($shipped);
$stmt = $pdo->prepare('SELECT name FROM customers WHERE city = ?');
$stmt->execute(['Belfast']);
var_dump($stmt->fetchColumn());
$stmt->execute(['York']);
var_dump($stmt->fetchColumn());
Output:
int(2)
string(11) "Aoife Byrne"
bool(false)
With PHP 8.4 and native prepares, the count comes back as a real int. Like fetch(), it returns false when there’s no row. That makes fetchColumn() a poor choice for columns that can legitimately hold false-like values, and you can’t get a second column from the same row afterwards. The PHP manual for fetchColumn() warns about that second point.
It’s tempting to call $stmt->rowCount() after a SELECT. Here’s what happened when I tried:
$stmt = $pdo->prepare('SELECT id FROM orders WHERE total > ?');
$stmt->execute([20]);
echo 'rowCount(): ', $stmt->rowCount(), "\n";
echo 'count(fetchAll()): ', count($stmt->fetchAll()), "\n";
$upd = $pdo->prepare('UPDATE orders SET status = ? WHERE status = ?');
$upd->execute(['shipped', 'paid']);
echo 'rows updated: ', $upd->rowCount(), "\n";
Output:
rowCount(): 3
count(fetchAll()): 3
rows updated: 2
It worked here, because MySQL buffers results by default. But the PHP manual says that for SELECT the behaviour is undefined and differs between drivers, so you shouldn’t rely on it in code that might run on another database. Use SELECT COUNT(*) with fetchColumn() for a count, or count() on the array from fetchAll(). rowCount() is for INSERT, UPDATE and DELETE, where it reliably tells you how many rows changed.
fetchAll() can reshape results for you, which saves a lot of foreach loops. These four modes are the ones I use most. The PDO fetch mode constants page lists the rest.
// FETCH_KEY_PAIR: first column becomes the key, second the value
$cities = $pdo->query('SELECT id, city FROM customers ORDER BY id')->fetchAll(PDO::FETCH_KEY_PAIR);
print_r($cities);
// FETCH_COLUMN: a flat list of one column
$names = $pdo->query('SELECT name FROM customers ORDER BY name')->fetchAll(PDO::FETCH_COLUMN);
print_r($names);
// FETCH_UNIQUE: rows keyed by the first column (the id)
$byId = $pdo->query('SELECT id, name, city FROM customers')->fetchAll(PDO::FETCH_UNIQUE);
print_r($byId[3]);
// FETCH_GROUP: rows grouped by the first column
$byStatus = $pdo->query('SELECT status, id, total FROM orders ORDER BY id')->fetchAll(PDO::FETCH_GROUP);
print_r($byStatus['paid']);
echo implode(', ', array_keys($byStatus)), "\n";
Output:
Array
(
[1] => Leeds
[2] => Cardiff
[3] => Belfast
[4] => Glasgow
)
Array
(
[0] => Aoife Byrne
[1] => Callum Reid
[2] => Priya Shah
[3] => Tom Evans
)
Array
(
[name] => Aoife Byrne
[city] => Belfast
)
Array
(
[0] => Array
(
[id] => 2
[total] => 8.50
)
[1] => Array
(
[id] => 3
[total] => 61.20
)
)
shipped, paid, refunded
FETCH_KEY_PAIR is perfect for a <select> dropdown: id => label. The query must return exactly two columns.FETCH_COLUMN gives a plain list, handy for an IN (...) check or in_array().FETCH_UNIQUE keys each row by its first column, so you can look rows up by ID without a loop. Note that the ID is removed from the row itself.FETCH_GROUP groups rows under the value of the first column. The groups appear in the order their first row did.A common follow-up is “fetch these five customers”. You can’t bind an array to one placeholder, so build one ? per value:
$ids = [1, 3, 4];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM customers WHERE id IN ($placeholders) ORDER BY id");
$stmt->execute($ids);
print_r($stmt->fetchAll(PDO::FETCH_KEY_PAIR));
Output:
Array
(
[1] => Priya Shah
[3] => Aoife Byrne
[4] => Callum Reid
)
The query string only ever contains question marks, never the values, so it stays safe from SQL injection.
If you prefer $row->name to $row['name'], use FETCH_OBJ. For real models, FETCH_CLASS creates an instance of your own class and fills its properties.
$row = $pdo->query('SELECT id, name, city FROM customers WHERE id = 4')->fetch(PDO::FETCH_OBJ);
echo $row->name, ' lives in ', $row->city, "\n";
final class Customer
{
public int $id;
public string $name;
public string $city;
public function label(): string
{
return "{$this->name} ({$this->city})";
}
}
$stmt = $pdo->query('SELECT id, name, city FROM customers ORDER BY id');
$customers = $stmt->fetchAll(PDO::FETCH_CLASS, Customer::class);
echo $customers[0]->label(), "\n";
var_dump($customers[0]->id);
Output:
Callum Reid lives in Glasgow
Priya Shah (Leeds)
int(1)
The typed int $id property worked without a cast because PDO returned the ID as an integer. PDO sets the properties before the constructor runs. If your constructor sets defaults that would overwrite them, add PDO::FETCH_PROPS_LATE to run the constructor first.
I checked what MySQL sends back for an INT, a DECIMAL and a DATE, with emulated prepares off and on:
EMULATE_PREPARES false:
array(3) {
["id"]=>
int(1)
["total"]=>
string(5) "24.99"
["placed_on"]=>
string(10) "2026-09-14"
}
EMULATE_PREPARES true:
array(3) {
["id"]=>
int(1)
["total"]=>
string(5) "24.99"
["placed_on"]=>
string(10) "2026-09-14"
}
On PHP 8.4, integers come back as int either way. DECIMAL values come back as strings, deliberately, so you don’t lose pennies to floating-point rounding. Keep money as a string or convert it to pence (an integer) before you do arithmetic. If you’re formatting prices for a page in pounds, my guide to formatting a number as currency covers the front-end side.
Most lists on a real site are paged: 20 posts per page, 50 orders per page. You get one page with LIMIT and OFFSET, then fetchAll() it. The values should come from placeholders like everything else, but there’s a catch with types, so bind them as integers:
$perPage = 2;
$page = 2;
$stmt = $pdo->prepare('SELECT id, total FROM orders ORDER BY id LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($page - 1) * $perPage, PDO::PARAM_INT);
$stmt->execute();
print_r($stmt->fetchAll(PDO::FETCH_KEY_PAIR));
Output:
Array
(
[3] => 61.20
[4] => 15.00
)
Page 2 skips the first two orders and returns orders 3 and 4, exactly as expected. Always add an ORDER BY when you paginate. Without one, the database is free to return rows in any order, and the same row can turn up on two pages.
Here’s why bindValue() with PDO::PARAM_INT matters. Passing values to execute() sends every one of them as a string. With native prepares (my connection above), MySQL copes. But plenty of shared hosts and older tutorials switch emulated prepares on, and then PHP pastes the value into the SQL as a quoted string:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
$stmt = $pdo->prepare('SELECT id FROM orders ORDER BY id LIMIT ?');
try {
$stmt->execute([2]);
echo "worked\n";
} catch (PDOException $e) {
echo $e->getMessage(), "\n";
}
Output:
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''2'' at line 1
The query became LIMIT '2', which MySQL rejects. Binding with PDO::PARAM_INT works in both modes, so it’s the habit to build. Work out the total number of pages with a separate SELECT COUNT(*) and fetchColumn(), as shown earlier, and cast the page number from the URL to an integer with a minimum of 1 before you use it.
After testing all of these, here’s the short version I keep in my head when I write a query:
fetch(), then check for === false. Return a 404 or an error message when it’s missing.fetchAll(PDO::FETCH_ASSOC). An empty result is an empty array, so your template can show “No orders yet” with a simple check.fetchColumn().fetchAll(PDO::FETCH_KEY_PAIR), or FETCH_UNIQUE if you need the whole row by ID.fetchAll(PDO::FETCH_GROUP).FETCH_CLASS, with FETCH_PROPS_LATE if your constructor sets defaults.foreach or while ($row = $stmt->fetch()) so memory stays flat.Whatever you pick, keep the query itself simple: list the columns you need instead of SELECT *. You transfer less data, aliases stay obvious, and adding a column to the table later can’t quietly change the shape of your arrays or overwrite a key in a JOIN.
These are the bugs I’ve hit or seen in code reviews, each with the output that proves it.
This one is sneaky. Both tables have an id column, and an associative array can only hold one id key:
$sql = 'SELECT o.id, o.total, c.id, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.id = 3';
print_r($pdo->query($sql)->fetch());
Output:
Array
(
[id] => 1
[total] => 61.20
[name] => Priya Shah
)
I asked for order 3, but id says 1. That’s the customer’s ID, which silently replaced the order’s. The fix is to alias every clashing column:
SELECT o.id AS order_id, o.total, c.id AS customer_id, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.id = 3
That returns order_id => 3 and customer_id => 1, both intact.
fetchAll() reads every remaining row. Anything you fetch afterwards gets nothing:
$stmt = $pdo->query('SELECT name FROM customers');
$all = $stmt->fetchAll();
var_dump($stmt->fetch());
Output:
bool(false)
If you need the first row as well as the full list, take it from the array: $first = $all[0] ?? null;.
if ($row == null). fetch() returns false for no row. Use === false.FETCH_BOTH. You’ll get every column twice. Set FETCH_ASSOC once in the connection options.prepare() and execute(), even for “safe” values such as IDs.fetchAll() on huge tables. Loop with fetch() instead, or add LIMIT and paginate.DECIMAL strings with floats. Convert to pence first.Once you can fetch rows reliably, you’re most of the way to a JSON API. My PHP REST API tutorial uses these same fetch() and fetchAll() calls to return JSON.
An array keyed by column name (or alias) for each row, for example ['id' => 1, 'name' => 'Priya Shah']. Unlike the default FETCH_BOTH, it doesn’t repeat each value under a numeric key.
fetch() returns the next single row, or false when there are no more. fetchAll() returns every remaining row as an array, or an empty array if there are none.
Because there’s no row to return. Either the query matched nothing or you’ve already read every row, for example by calling fetchAll() first. With ERRMODE_EXCEPTION set, a real SQL error throws a PDOException instead.
Run SELECT COUNT(*) ... and read it with fetchColumn(), or call count() on the array from fetchAll(). Don’t rely on rowCount() for SELECT statements, because the PHP manual says its behaviour there differs between drivers.
Pass PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC in the options array when you create the PDO object. Every fetch() and fetchAll() call then returns associative arrays unless you ask for something else.
Select exactly two columns and use fetchAll(PDO::FETCH_KEY_PAIR). The first column becomes the key and the second becomes the value, which is ideal for dropdown menus.
On PHP 8.1 and later with MySQL, integer columns come back as int, as my test on PHP 8.4 showed. DECIMAL columns come back as strings on purpose, so prices keep their exact value.
fetch(PDO::FETCH_ASSOC) gets one row, fetchAll(PDO::FETCH_ASSOC) gets them all, and fetchColumn() gets one value. Set the fetch mode once in your connection, check for false with ===, alias clashing columns in JOINs, and count with COUNT(*) rather than rowCount(). Those habits cover nearly every PDO query you’ll write. To see fetch() and the === false check in a real feature, try my PHP login and session example, which looks up a user by email before checking the password.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Learn PHP PDO prepared statements with MySQL: connect safely, insert, select, update and delete, plus LIKE, IN lists, LIMIT, transactions and SQL injection.
Find duplicates with GROUP BY and HAVING, preview them with ROW_NUMBER(), then delete all but one with a self-join. Error 1093, NULLs, case and spaces, and a UNIQUE key.
A tested PHP login with sessions and PDO: password_verify, session_regenerate_id on success, a protected dashboard, and a logout that clears the session cookie.
A complete JSON REST API in plain PHP 8.4: front controller, router, PDO and SQLite, validation with 422 errors, Bearer token auth and CORS. Every request tested, with real responses.