PHP PDO Tutorial: The Complete Guide to Safe Database Code (2026)
A complete PHP PDO tutorial: connect, prepared statements, fetch modes, insert and lastInsertId, transactions, password hashing and secure forms, with linked guides.
Insert a row with PHP PDO prepared statements and get the new ID with lastInsertId(). MySQL, PostgreSQL RETURNING, SQLite, bulk inserts and common mistakes.
Inserting a row with PDO takes three lines. Getting the ID of the row you just created is where people trip up, because lastInsertId() behaves a little differently on MySQL, PostgreSQL and SQLite. This guide shows a safe insert with a prepared statement, then how to read the new ID correctly on each database, plus the mistakes I see most often in code reviews.
If you are new to PDO, read PHP PDO prepared statements explained first. This post builds on it.
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->execute(['name' => $name, 'email' => $email]);
$id = (int) $pdo->lastInsertId();
Call lastInsertId() on the PDO connection, not on the statement, and call it straight after the insert on the same connection.
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
Since PHP 8.0, exception mode is the default, but setting it explicitly makes the intent obvious. Turning off emulated prepares makes MySQL do real server-side prepares, so integers come back as integers.
Both styles are equally safe. Pick one per query and do not mix them.
// Named
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (:uid, :total)');
$stmt->execute(['uid' => 42, 'total' => '19.99']);
// Positional
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');
$stmt->execute([42, '19.99']);
Never build the SQL with string concatenation such as "VALUES ('$name')". That is the classic SQL injection hole that prepared statements exist to close.
On MySQL, lastInsertId() returns the AUTO_INCREMENT value from the last insert on this connection. Other users inserting at the same moment do not affect it, so you do not need a SELECT MAX(id) (which is a race condition and should be avoided).
Two details worth knowing:
"105". Cast it with (int) if you need a number.INSERT ... VALUES (...), (...), (...), MySQL returns the ID of the first row inserted, not the last.PostgreSQL uses sequences. You can pass the sequence name:
$id = $pdo->lastInsertId('users_id_seq');
The cleaner way is RETURNING, which gives you the ID (or any column) in the same round trip:
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?) RETURNING id');
$stmt->execute([$name, $email]);
$id = (int) $stmt->fetchColumn();
SQLite works like MySQL: $pdo->lastInsertId() returns the rowid of the last inserted row. Recent SQLite versions (3.35 and later) also support RETURNING id, so the PostgreSQL pattern above works there too.
Prepare once, execute many times, and wrap the loop in a transaction. Without the transaction, each insert is committed on its own, which is much slower.
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
foreach ($names as $n) {
$stmt->execute([$n]);
}
$pdo->commit();
I explain commit and rollback properly in PHP PDO transactions with commit and rollback.
If email has a unique index, inserting the same email twice throws a PDOException with SQLSTATE 23000. Catch it and show a friendly message instead of a stack trace:
try {
$stmt->execute([$name, $email]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
$error = 'That email is already registered.';
} else {
throw $e;
}
}
$stmt->lastInsertId() does not exist. Use $pdo.Yes. It is scoped to the current database connection, so concurrent requests each get their own value.
PDO returns it as a string on every driver so very large IDs are not lost. Cast it with (int) when you know it fits.
No. It reports IDs generated by inserts. After an update, use $stmt->rowCount() to see how many rows changed.
On PostgreSQL and SQLite 3.35+, use RETURNING id and fetch all rows. On MySQL, insert in a loop and collect lastInsertId() after each execute.
Want to skip the typing? My free PDO prepared statement generator writes the INSERT, SELECT, UPDATE and DELETE code for your table.