The fundamental difference is how SQL is sent to the database:

FeatureStatementPreparedStatement
SQL compilationEvery callOnce at prepareStatement()
User inputString concatenation — unsafeBind parameters — safe
SQL injectionVulnerableNot possible via parameters
Performance (repeated)Parse overhead each timePlan cache reused
Batch supportPossible but unusualFirst-class addBatch()
Default rule: always use PreparedStatement for any SQL that involves application data. Use Statement only for static DDL scripts where no user data is involved.

Consider a login query built with string concatenation:

// ❌ DANGEROUS — never do this String username = request.getParameter("username"); // user input String sql = "SELECT * FROM users WHERE username = '" + username + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);

If the attacker sends username = ' OR '1'='1, the SQL becomes:

SELECT * FROM users WHERE username = '' OR '1'='1' -- Returns ALL rows — authentication bypassed entirely

A more destructive payload like ' ; DROP TABLE users; -- can destroy data. PreparedStatement prevents this entirely — the parameter value is never interpreted as SQL syntax.

Replace concatenated values with ? placeholders and set them via typed setters:

String sql = "SELECT id, name, email FROM users WHERE email = ? AND active = ?"; try (Connection conn = DriverManager.getConnection(URL, USER, PASS); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "alice@example.com"); // 1-based index ps.setBoolean(2, true); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(rs.getLong("id") + " " + rs.getString("name")); } } }

Use the setter that matches the SQL column type:

Java TypeSetter
StringsetString(index, value)
intsetInt(index, value)
longsetLong(index, value)
doublesetDouble(index, value)
BigDecimalsetBigDecimal(index, value)
booleansetBoolean(index, value)
java.sql.DatesetDate(index, value)
java.sql.TimestampsetTimestamp(index, value)
byte[]setBytes(index, value)
SQL NULLsetNull(index, Types.VARCHAR)
Any objectsetObject(index, value) — driver infers type
String sql = "INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)"; try (Connection conn = DriverManager.getConnection(URL, USER, PASS); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, "Charlie"); ps.setString(2, "charlie@example.com"); ps.setTimestamp(3, Timestamp.from(Instant.now())); int rows = ps.executeUpdate(); System.out.println("Inserted: " + rows); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { System.out.println("New ID: " + keys.getLong(1)); } } }

Sending multiple rows in one round-trip is dramatically faster than individual inserts. Call addBatch() after each row, then executeBatch() once:

List<User> users = loadUsersFromCsv(); // large list String sql = "INSERT INTO users (name, email) VALUES (?, ?)"; try (Connection conn = DriverManager.getConnection(URL, USER, PASS); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); // wrap in a transaction for atomicity int batchSize = 500; int count = 0; for (User u : users) { ps.setString(1, u.getName()); ps.setString(2, u.getEmail()); ps.addBatch(); if (++count % batchSize == 0) { ps.executeBatch(); // flush every 500 rows } } ps.executeBatch(); // flush remainder conn.commit(); System.out.println("Inserted " + count + " users"); } catch (SQLException ex) { conn.rollback(); throw ex; } PostgreSQL reWriteBatchedInserts=true in the JDBC URL converts individual batch entries into a single multi-row INSERT statement, giving another significant speed boost.

A single PreparedStatement can be re-executed with different parameter values without re-parsing. Call clearParameters() (optional) between uses if you want to reset all parameters first:

String sql = "SELECT * FROM orders WHERE customer_id = ?"; try (Connection conn = DriverManager.getConnection(URL, USER, PASS); PreparedStatement ps = conn.prepareStatement(sql)) { long[] customerIds = {1L, 42L, 99L}; for (long customerId : customerIds) { ps.setLong(1, customerId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println("Order: " + rs.getLong("id")); } } // ps is reused — SQL is already compiled } }