☕ Java
PreparedStatement
java.sql.PreparedStatement extends Statement to support parameterized SQL: a SQL string containing one or more '?' placeholders, prepared once and then executed any number of times with different bound values supplied separately from the SQL text itself. This separation of SQL structure from data is what makes PreparedStatement both safe against SQL injection and, in many database/driver combinations, faster for repeated execution, since the database can parse and plan the statement once and reuse that plan across executions. This entry covers creating and binding parameters on a PreparedStatement, batch execution with bound parameters, and retrieving auto-generated keys after an insert.
Parameter Placeholders and Type-Specific Setters
A PreparedStatement is created by calling conn.prepareStatement(sql) with a SQL string containing '?' characters as positional placeholders for values that will be supplied later — each '?' corresponds to one parameter, numbered starting from 1 (not 0) in the order they appear in the SQL text. Before executing, each placeholder must be bound to a concrete value using one of the type-specific setter methods — setString(), setInt(), setLong(), setBigDecimal(), setDate(), setTimestamp(), setBoolean(), and so on — with the first argument always being the 1-based parameter index and the second the value itself; calling setNull(index, sqlType) is required to bind an actual SQL NULL, since passing a Java null reference to most setters either throws an exception or behaves inconsistently across drivers.
Critically, the value passed to a setter is never parsed as SQL syntax — the driver sends it to the database as a typed data value associated with that specific parameter slot, which is what eliminates SQL injection as a risk for that value (assuming the SQL text itself, including which columns and tables are referenced, doesn't also incorporate untrusted input via string concatenation, since parameters only work for values, not for identifiers like table or column names). If a PreparedStatement is reused across multiple executions with different parameter values, calling clearParameters() resets all bound values, though it's just as common to simply set new values for every placeholder again before the next execute call, since setters overwrite any previous binding for that index.
Java
import java.sql.*;
import java.math.BigDecimal;
import java.time.LocalDate;
public class PreparedStatementBasics {
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/storedb", "app_user", "secret")) {
// ── Binding multiple typed parameters by 1-based position ──────
String sql = "INSERT INTO orders (customer_id, total, order_date, notes) " +
"VALUES (?, ?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, 1001L);
ps.setBigDecimal(2, new BigDecimal("249.99"));
ps.setDate(3, java.sql.Date.valueOf(LocalDate.now()));
ps.setNull(4, Types.VARCHAR); // explicit SQL NULL, not a Java null reference
ps.executeUpdate();
}
// ── Reusing one PreparedStatement across multiple executions ───
String selectSql = "SELECT name, price FROM products WHERE category = ? AND price < ?";
try (PreparedStatement ps = conn.prepareStatement(selectSql)) {
for (String category : new String[] {"electronics", "books", "toys"}) {
ps.setString(1, category);
ps.setBigDecimal(2, new BigDecimal("50.00"));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(category + ": " + rs.getString("name")
+ " $" + rs.getBigDecimal("price"));
}
}
ps.clearParameters(); // optional here, since both setters are re-called each loop
}
}
}
}
}Batch Execution with Bound Parameters
PreparedStatement supports the same addBatch()/executeBatch() pattern as Statement, but because the SQL text is fixed and only the parameter values vary, batching with PreparedStatement is both safer (parameters are still bound, not concatenated) and typically more efficient than batching with plain Statement, since the database parses the SQL structure only once regardless of how many parameter sets are queued. The pattern is: bind parameters for one logical row or operation, call addBatch() with no SQL argument (the SQL was already supplied when the PreparedStatement was created), then repeat for each subsequent set of values before finally calling executeBatch() once to send them all together.
This is the standard approach for bulk inserts and bulk updates from application code — for example, inserting thousands of rows parsed from a file — and most JDBC drivers and many ORMs build their own bulk-insert optimizations on top of exactly this mechanism. As with Statement batching, executeBatch() returns an int array of per-statement update counts, and a BatchUpdateException can be thrown if one of the batched parameter sets fails (e.g. a constraint violation on one row in the middle of a large batch), with getUpdateCounts() reporting how many of the batched operations completed before the failure.
Java
import java.sql.*;
import java.math.BigDecimal;
import java.util.List;
public class PreparedStatementBatch {
record ProductRow(String name, BigDecimal price, String category) {}
public static void bulkInsert(Connection conn, List<ProductRow> rows) throws SQLException {
String sql = "INSERT INTO products (name, price, category) VALUES (?, ?, ?)";
boolean originalAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(sql)) {
int batchSize = 0;
for (ProductRow row : rows) {
ps.setString(1, row.name());
ps.setBigDecimal(2, row.price());
ps.setString(3, row.category());
ps.addBatch(); // queue this parameter set; SQL text unchanged
batchSize++;
// Flushing every 1000 rows avoids holding an enormous batch in memory
if (batchSize % 1000 == 0) {
ps.executeBatch();
ps.clearBatch();
}
}
ps.executeBatch(); // flush any remaining rows under the last 1000-row boundary
conn.commit();
} catch (BatchUpdateException e) {
conn.rollback();
System.err.println("Batch insert failed after " + e.getUpdateCounts().length + " statements");
throw e;
} finally {
conn.setAutoCommit(originalAutoCommit);
}
}
}Retrieving Auto-Generated Keys After an Insert
Many tables use a database-generated primary key (an auto-increment column, an identity column, or a sequence-backed default) rather than having the application supply the key value explicitly, which raises the question of how application code finds out what key value the database actually assigned after an INSERT completes. PreparedStatement addresses this with a special overload of prepareStatement(sql, autoGeneratedKeys) where the second argument is the constant Statement.RETURN_GENERATED_KEYS, signaling to the driver that it should track and expose whatever key value the database generated during the insert.
After calling executeUpdate() on a PreparedStatement created this way, getGeneratedKeys() returns a ResultSet — typically containing just the generated key column, though the exact columns returned can vary by driver and database — which is read the same way as any other ResultSet, by calling next() and then a typed getter. Support and exact behavior for generated keys varies somewhat across database vendors (some support returning only a single generated column, others support returning entire inserted rows via RETURNING-style clauses as a vendor extension instead of relying on this generic API), so for databases like PostgreSQL it's also common to retrieve generated values directly through a RETURNING clause in the SQL itself rather than through getGeneratedKeys(), depending on which approach the team's driver and database combination handles most reliably.
Java
import java.sql.*;
public class GeneratedKeysExample {
public static long insertAndGetId(Connection conn, String name, java.math.BigDecimal price)
throws SQLException {
String sql = "INSERT INTO products (name, price) VALUES (?, ?)";
// Tell the driver to track whatever key the database generates
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, name);
ps.setBigDecimal(2, price);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
return keys.getLong(1); // generated key is usually the first/only column
}
throw new SQLException("Insert succeeded but no generated key was returned");
}
}
}
// ── Alternative on databases supporting RETURNING (e.g. PostgreSQL) ───────
public static long insertAndGetIdWithReturning(Connection conn, String name, java.math.BigDecimal price)
throws SQLException {
String sql = "INSERT INTO products (name, price) VALUES (?, ?) RETURNING id";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
ps.setBigDecimal(2, price);
try (ResultSet rs = ps.executeQuery()) { // note: executeQuery, since RETURNING yields rows
rs.next();
return rs.getLong("id");
}
}
}
}Related Topics in Java Database Connectivity (JDBC)
JDBC Architecture
JDBC (Java Database Connectivity) is the standard Java API for connecting to and executing operations against relational databases, defined as a set of interfaces in java.sql and javax.sql that database vendors implement with concrete driver classes. Its architecture is deliberately layered to decouple application code from any specific database product: applications code against interfaces like Connection, Statement, and ResultSet, while a pluggable driver underneath translates those calls into the vendor-specific network protocol the database actually understands. This entry covers the two-layer (API/driver) architecture, the four historical driver types, the role of the JDBC driver manager and connection pooling layers, and how the pieces fit together at runtime.
JDBC Drivers
A JDBC driver is the vendor-specific library that implements the java.sql/javax.sql interfaces for a particular database product, translating standard JDBC calls into that database's native network protocol. Choosing, loading, registering, and configuring the correct driver is a prerequisite for any JDBC code to run, and the mechanics of how a driver gets discovered and instantiated have evolved significantly since JDBC's introduction. This entry covers driver registration via the Service Provider Interface (SPI) versus the older explicit Class.forName() approach, JDBC URL syntax and structure, driver-specific configuration properties, and how to choose and manage driver dependencies in a real project.
DriverManager
java.sql.DriverManager is the original, classic entry point into JDBC: a static factory class that maintains a registry of available Driver implementations and hands back a live Connection when given a JDBC URL and credentials. It predates the javax.sql.DataSource abstraction and connection pooling, and while it remains fully functional and is still commonly used for simple applications, scripts, and tests, production server-side code generally prefers DataSource-based connection pooling for the reasons covered below. This entry covers how DriverManager locates and selects a driver, its core methods, login timeout and logging configuration, and where it fits (and doesn't fit) in modern application architecture.
Connection
java.sql.Connection represents a single session with a specific database — the live link through which all statements, transactions, and metadata queries flow, and the factory from which Statement, PreparedStatement, and CallableStatement objects are created. A Connection is a relatively expensive, stateful resource that wraps an underlying network socket and server-side session, which is why its lifecycle, transaction settings, and proper closing matter so much in practice. This entry covers obtaining and closing connections, transaction control (auto-commit, commit, rollback, savepoints, isolation levels), creating statements from a connection, and connection metadata and health checks.