How do I use connection pooling in JDBC with HikariCP?

To use connection pooling in plain JDBC with HikariCP, the main shift is:

  • stop using DriverManager.getConnection(...) everywhere
  • create one DataSource (the pool) at startup
  • whenever you need a DB connection, call dataSource.getConnection()
  • always close resources with try-with-resources (closing returns the connection to the pool, it does not kill the physical connection)

1) Create a pooled DataSource once

A simple “factory” that builds a singleton pool:

package org.kodejava.jdbc;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import javax.sql.DataSource;
import java.time.Duration;

public final class DataSourceFactory {
    private static final HikariDataSource dataSource = create();

    private DataSourceFactory() {}

    private static HikariDataSource create() {
        HikariConfig config = new HikariConfig();

        config.setJdbcUrl("jdbc:postgresql://localhost:5432/app_db");
        config.setUsername("db_user");
        config.setPassword("db_password"); // use env vars/secret store in real apps

        // Pool sizing (tune per app + DB limits)
        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);

        // Timeouts
        config.setConnectionTimeout(Duration.ofSeconds(5).toMillis()); // wait for a connection from pool
        config.setIdleTimeout(Duration.ofMinutes(5).toMillis());
        config.setMaxLifetime(Duration.ofMinutes(30).toMillis());

        // Optional: validation / observability
        config.setPoolName("AppHikariPool");

        return new HikariDataSource(config);
    }

    public static DataSource getDataSource() {
        return dataSource;
    }

    /** Call this on application shutdown */
    public static void shutdown() {
        dataSource.close();
    }
}

Notes:

  • maximumPoolSize is usually the most important setting.
  • Prefer one pool per database, not one per DAO/class.

2) Use it in JDBC code (and always close)

Example query using the pooled DataSource:

package org.kodejava.jdbc;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class UserRepository {
    private final DataSource dataSource;

    public UserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public String findEmailById(long id) throws SQLException {
        String sql = "select email from users where id = ?";

        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(sql)) {

            ps.setLong(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getString("email") : null;
            }
        }
    }
}

Key point: con.close() (done by try-with-resources) returns the connection to the pool.


3) Shutdown cleanly

If you’re writing a CLI app / desktop app / simple server, ensure the pool is closed on exit:

package org.kodejava.jdbc;

public class App {
    public static void main(String[] args) throws Exception {
        var ds = DataSourceFactory.getDataSource();
        var repo = new UserRepository(ds);

        System.out.println(repo.findEmailById(1L));

        DataSourceFactory.shutdown();
    }
}

For long-running apps, register a shutdown hook:

Runtime.getRuntime().addShutdownHook(new Thread(DataSourceFactory::shutdown));

4) Common configuration tips (practical)

  • Pool size: start with maximumPoolSize=10 for typical web apps, then tune using metrics and DB limits.
  • Don’t set minimumIdle too high unless you truly need warm connections.
  • Transactions: still work the same (use con.setAutoCommit(false) and commit/rollback), but make sure you always return the connection to the pool.
  • If you see “connection leak” warnings, it usually means some path didn’t close the connection (missing try-with-resources).

Maven dependencies

<dependencies>
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.3.0</version>
  </dependency>

  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.7</version>
  </dependency>
</dependencies>

Maven Central Maven Central

How do I use savepoints in JDBC transactions?

Savepoints in JDBC provide fine-grained control over transactions by allowing you to roll back to a specific point within a transaction rather than undoing everything. This is particularly useful for handling optional operations or partial failures.

Key Steps to Use Savepoints

  1. Disable Auto-commit: Savepoints only work within a manual transaction.
  2. Set a Savepoint: Use connection.setSavepoint() to mark a logical point in your execution.
  3. Rollback to Savepoint: If an error occurs, use connection.rollback(savepoint).
  4. Release or Commit: Release the savepoint once it’s no longer necessary (though commit or a full rollback will also clear them).

Implementation Example

Here is how you can implement this in your project.

package org.kodejava.jdbc;

import java.sql.*;

public class SavepointExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/your_database";
        String user = "root";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, user, password)) {
            // 1. Disable auto-commit
            conn.setAutoCommit(false);

            try (Statement stmt = conn.createStatement()) {
                // Execute a required operation
                stmt.executeUpdate("INSERT INTO orders (item, qty) VALUES ('Laptop', 1)");

                // 2. Set a savepoint before an "optional" or risky operation
                Savepoint savepoint1 = conn.setSavepoint("Savepoint1");

                try {
                    // Try an optional operation (e.g., updating a secondary table)
                    stmt.executeUpdate("INSERT INTO loyalty_points (user_id, points) VALUES (1, 100)");
                } catch (SQLException e) {
                    // 3. Roll back to the savepoint if the optional part fails
                    System.out.println("Optional operation failed, rolling back to savepoint.");
                    conn.rollback(savepoint1);
                }

                // 4. Commit the overall transaction
                conn.commit();
                System.out.println("Transaction committed successfully.");

            } catch (SQLException e) {
                // If the main operation fails, roll back everything
                conn.rollback();
                e.printStackTrace();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Things to Keep in Mind

  • Named vs. Unnamed Savepoints: conn.setSavepoint() returns an unnamed savepoint with a system-generated ID. conn.setSavepoint("Name") creates a named one which can be easier for debugging.
  • Release Savepoints: While not strictly mandatory in all drivers, calling connection.releaseSavepoint(savepoint) can help free up resources if you have many savepoints in a long-running transaction.
  • Driver Support: Most modern databases (MySQL, PostgreSQL, Oracle, SQL Server) support savepoints, but you can check programmatically using DatabaseMetaData.supportsSavepoints().
  • Transaction Scope: Once a transaction is committed or rolled back entirely, all associated savepoints are automatically released and become invalid.

How do I use transactions in JDBC?

Using transactions in JDBC is essential when you need to ensure that a group of SQL statements either all succeed or all fail together (maintaining Atomicity).

By default, a JDBC Connection is in auto-commit mode, meaning every single SQL statement is treated as its own transaction and committed immediately.

To manage transactions manually, follow these three main steps:

1. Disable Auto-Commit

The first step is to tell the connection not to commit automatically after every execution.

connection.setAutoCommit(false);

2. Perform Your Database Operations

Execute your SQL statements (inserts, updates, deletes). If any of these throw an exception, you should catch it to handle the failure.

3. Commit or Rollback

  • commit(): If everything went well, save the changes permanently.
  • rollback(): If an error occurred, undo all changes made since the last commit.

Basic Example

Here is a clean pattern using a try-with-resources block for the connection and a nested try-catch for the transaction logic:

try (Connection conn = DriverManager.getConnection(url, user, pass)) {
    // Step 1: Disable auto-commit
    conn.setAutoCommit(false);

    try (PreparedStatement pstmt1 = conn.prepareStatement("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
         PreparedStatement pstmt2 = conn.prepareStatement("UPDATE accounts SET balance = balance + 100 WHERE id = 2")) {

        // Execute operations
        pstmt1.executeUpdate();
        pstmt2.executeUpdate();

        // Step 3a: Commit changes
        conn.commit();
        System.out.println("Transaction committed successfully!");

    } catch (SQLException e) {
        // Step 3b: Rollback changes if something goes wrong
        conn.rollback();
        System.err.println("Transaction rolled back due to error.");
        e.printStackTrace();
    }
} catch (SQLException e) {
    e.printStackTrace();
}

Important Tips:

  • Always use rollback() in the catch block: If you don’t roll back on failure, the connection might hold onto locks or leave the session in an inconsistent state.
  • Savepoints: If you have a very long transaction and want to roll back only a part of it, you can use conn.setSavepoint().
  • Transaction Isolation: You can control how “isolated” your transaction is from others using conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE), though the default is usually enough for standard applications.

How do I set fetch size for large queries?

To set the fetch size for large queries in Java using JDBC, you use the setFetchSize(int rows) method on a Statement or PreparedStatement object.

This gives the JDBC driver a hint as to the number of rows that should be fetched from the database when more rows are needed. This is particularly useful for large result sets to avoid loading everything into memory at once or to reduce the number of network round-trips.

Using JDBC Statement

Here is how you can apply it to a standard Statement:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

// ... existing code ...
try (Connection conn = DriverManager.getConnection(url, user, password);
     Statement stmt = conn.createStatement()) {

    // Set the fetch size to 100 rows
    stmt.setFetchSize(100);

    try (ResultSet rs = stmt.executeQuery("SELECT * FROM very_large_table")) {
        while (rs.next()) {
            // Process rows
        }
    }
} catch (Exception e) {
    e.printStackTrace();
}

Important Considerations

  1. Driver Support: setFetchSize is a hint. Not all JDBC drivers honor this value in the same way.
  2. MySQL Specifics: By default, the MySQL driver fetches all rows into memory. To stream results (fetch row-by-row), you must set the fetch size to Integer.MIN_VALUE and use a forward-only, read-only result set:
    stmt.setFetchSize(Integer.MIN_VALUE);
    
  3. Oracle Specifics: Oracle has a default fetch size (usually 10). Increasing this to 100 or 500 can significantly improve performance for large queries.

  4. Memory vs. Network:
    • Small fetch size: Saves memory but increases network round-trips (slower).
    • Large fetch size: Reduces network round-trips (faster) but consumes more client-side memory.

Using Spring Data JPA / Jakarta EE

Since your project uses Spring Data JPA, you can also set the fetch size using the @QueryHints annotation on your repository methods:

import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.QueryHints;
import static org.hibernate.jpa.HibernateHints.HINT_FETCH_SIZE;

@QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "100"))
List<User> findAllByStatus(String status);

How do I use auto-generated keys in JDBC?

To use auto-generated keys in JDBC (like an AUTO_INCREMENT primary key), you need to follow a three-step process: notify the statement you want the keys, execute the update, and then retrieve them from a special ResultSet.

Here is a practical example using PreparedStatement:

1. Prepare the Statement

When creating your PreparedStatement, you must pass the constant Statement.RETURN_GENERATED_KEYS to let the driver know you want the keys back.

String sql = "INSERT INTO users (username, email) VALUES (?, ?)";
try (PreparedStatement pstmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
    pstmt.setString(1, "john_doe");
    pstmt.setString(2, "[email protected]");

    // ...
}

2. Execute and Retrieve

After calling executeUpdate(), use getGeneratedKeys() to fetch the IDs. Even if you only inserted one row, the keys are returned as a ResultSet because some databases support multiple generated keys per row or batch inserts.

int affectedRows = pstmt.executeUpdate();

if (affectedRows > 0) {
    try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
        if (generatedKeys.next()) {
            long id = generatedKeys.getLong(1);
            System.out.println("Inserted record ID: " + id);
        }
    }
}

Complete Example

Based on standard JDBC practices, here is how the implementation usually looks:

package org.kodejava.jdbc;

import java.sql.*;

public class GetGeneratedKey {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/your_db";
        String user = "root";
        String password = "password";

        String sql = "INSERT INTO authors (name) VALUES (?)";

        try (Connection conn = DriverManager.getConnection(url, user, password);
             PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

            pstmt.setString(1, "Herbert Schildt");
            pstmt.executeUpdate();

            // Retrieve the generated key
            try (ResultSet rs = pstmt.getGeneratedKeys()) {
                if (rs.next()) {
                    long generatedId = rs.getLong(1);
                    System.out.println("Generated ID: " + generatedId);
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Key Points to Remember:

  • Constant: Don’t forget Statement.RETURN_GENERATED_KEYS. Without it, getGeneratedKeys() will return an empty result set or throw an exception depending on the driver.
  • Column Index: Usually, the generated key is in the first column of the returned ResultSet, so rs.getLong(1) is standard.
  • Database Support: Most modern databases (MySQL, PostgreSQL, SQL Server, Oracle) support this, though the internal mechanism (Sequences vs. Identity columns) varies.

How do I batch insert data with JDBC?

To batch insert data with JDBC, you typically use the addBatch() and executeBatch() methods. This is much more efficient than executing individual INSERT statements because it reduces the number of round-trips between your application and the database.

The most common and secure way to do this is with a PreparedStatement.

Batch Insert with PreparedStatement

Using PreparedStatement allows you to define a template query and then add multiple sets of parameters to a single batch.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class JDBCBatchInsert {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/your_db";
        String user = "user";
        String password = "password";

        String sql = "INSERT INTO employees (name, department) VALUES (?, ?)";

        try (Connection conn = DriverManager.getConnection(url, user, password)) {
            // 1. Disable auto-commit for better performance and transaction control
            conn.setAutoCommit(false);

            try (PreparedStatement pstmt = conn.prepareStatement(sql)) {

                // Add first record to batch
                pstmt.setString(1, "Alice");
                pstmt.setString(2, "Engineering");
                pstmt.addBatch();

                // Add second record to batch
                pstmt.setString(1, "Bob");
                pstmt.setString(2, "Marketing");
                pstmt.addBatch();

                // 2. Execute the batch
                int[] results = pstmt.executeBatch();

                // 3. Commit the transaction
                conn.commit();
                System.out.println("Batch executed. Rows affected: " + results.length);

            } catch (SQLException e) {
                // Rollback in case of error
                conn.rollback();
                e.printStackTrace();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Key Considerations

  1. setAutoCommit(false): By default, JDBC commits every statement individually. Turning this off allows the entire batch to be treated as a single transaction, which significantly boosts performance.
  2. addBatch(): Adds the current set of parameters to the internal list of commands.
  3. executeBatch(): Sends all the gathered commands to the database. It returns an int[] where each element represents the update count for the corresponding command in the batch.
  4. Batch Size: For very large datasets (e.g., thousands of rows), don’t add everything to a single batch. Instead, execute the batch every 500–1000 rows to avoid memory issues:
    if (count % 1000 == 0) {
        pstmt.executeBatch();
        conn.commit(); // Optional: commit periodically
    }
    

Using Statement

While possible, using Statement.addBatch(String sql) is generally discouraged for inserts involving variables because it is vulnerable to SQL injection and harder for the database to optimize. Use PreparedStatement whenever possible.

How do I delete rows with JDBC?

To delete rows from a database using JDBC, you use the executeUpdate() method. This method is used for SQL statements that modify data (like DELETE, INSERT, or UPDATE) and returns an integer representing the number of rows affected.

While you can use a simple Statement, it is highly recommended to use a PreparedStatement to prevent SQL injection and handle parameters safely.

Example: Deleting a Row with PreparedStatement

Here is a typical implementation using the try-with-resources pattern to ensure database connections are closed properly:

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class DeleteRowExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/your_database";
        String user = "username";
        String password = "password";

        // SQL query with a placeholder (?) for the ID
        String sql = "DELETE FROM users WHERE id = ?";

        try (Connection conn = DriverManager.getConnection(url, user, password);
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            // Set the value for the placeholder
            int idToDelete = 101;
            pstmt.setInt(1, idToDelete);

            // Execute the delete operation
            int rowsDeleted = pstmt.executeUpdate();

            if (rowsDeleted > 0) {
                System.out.println("Successfuly deleted " + rowsDeleted + " row(s).");
            } else {
                System.out.println("No record found with the specified ID.");
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Key Steps:

  1. Prepare the SQL: Use the DELETE syntax. Use ? as placeholders for dynamic values.
  2. Create a PreparedStatement: Call connection.prepareStatement(sql).
  3. Bind Parameters: Use setter methods like setInt(), setString(), or setLong() to provide values for the placeholders (indices start at 1).
  4. Execute Update: Call executeUpdate(). It returns the count of deleted rows.
  5. Handle Exceptions: Wrap the code in a try-catch block to handle SQLException.

Which method should I use?

  • executeUpdate(): Use this for DELETE statements. It returns the number of rows removed.
  • Statement: Use only for static SQL with no user input.
  • PreparedStatement: Always preferred for security and performance when using variables in your WHERE clause.

How do I insert rows with JDBC?

To insert rows into a database using JDBC, you typically use the executeUpdate(String sql) method of a Statement or PreparedStatement object.

Here are the two primary ways to do it:

1. Using PreparedStatement (Recommended)

This is the standard approach because it prevents SQL Injection and is more efficient for repeated inserts.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class InsertRecordExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/kodejava";
        String user = "root";
        String password = "password";

        String sql = "INSERT INTO users (username, email) VALUES (?, ?)";

        try (Connection conn = DriverManager.getConnection(url, user, password);
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            // Set the values for the placeholders (?)
            pstmt.setString(1, "john_doe");
            pstmt.setString(2, "[email protected]");

            // executeUpdate returns the number of rows affected
            int rowsInserted = pstmt.executeUpdate();
            if (rowsInserted > 0) {
                System.out.println("A new user was inserted successfully!");
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

2. Using Statement

Use this only for simple, static SQL queries where no user input is involved.

// ... existing code ...
    try (Connection conn = DriverManager.getConnection(url, user, password);
         Statement stmt = conn.createStatement()) {

        String sql = "INSERT INTO users (username, email) VALUES ('jane_doe', '[email protected]')";
        int rows = stmt.executeUpdate(sql);

        System.out.println("Rows affected: " + rows);
    } catch (SQLException e) {
        e.printStackTrace();
    }
// ... existing code ...

Key Takeaways:

  • executeUpdate(): Unlike executeQuery() (which returns a ResultSet), executeUpdate() returns an int representing how many rows were added, changed, or deleted.
  • Try-with-resources: Always wrap your Connection, Statement, or PreparedStatement in a try-with-resources block to ensure they are closed automatically, even if an error occurs.
  • Placeholders: In a PreparedStatement, indices for ? parameters start at 1.

How do I update rows with executeUpdate?

In JDBC, the executeUpdate method is used for SQL statements that modify data, such as UPDATE, INSERT, or DELETE. Unlike executeQuery, which returns a ResultSet, executeUpdate returns an int representing the number of rows affected by the operation.

Here is how you can update rows using PreparedStatement (the recommended way) and Statement.

1. Using PreparedStatement (Recommended)

This approach is more secure as it prevents SQL injection and is more efficient for queries used multiple times.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class UpdateExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/musicdb";
        String user = "root";
        String password = "password";

        String sql = "UPDATE album SET title = ? WHERE id = ?";

        try (Connection conn = DriverManager.getConnection(url, user, password);
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            // Bind values to the placeholders (?)
            pstmt.setString(1, "The White Album");
            pstmt.setLong(2, 10L);

            // Execute the update
            int rowsAffected = pstmt.executeUpdate();

            System.out.println("Rows updated: " + rowsAffected);

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

2. Using Statement

Use this only for simple, static SQL queries where no user input is involved.

String sql = "UPDATE album SET title = 'Rubber Soul' WHERE id = 5";

try (Connection conn = DriverManager.getConnection(url, user, password);
     Statement stmt = conn.createStatement()) {

    int rowsAffected = stmt.executeUpdate(sql);
    System.out.println("Rows updated: " + rowsAffected);
}

Key Points to Remember:

  • Return Value: The method returns the number of rows changed. If it returns 0, it means no rows matched your WHERE clause.
  • Transaction Control: If you have set connection.setAutoCommit(false), you must call connection.commit() after executeUpdate() to persist the changes to the database.
  • Resource Management: Always use a try-with-resources block to ensure the Connection and Statement are closed automatically, which prevents database connection leaks.

How do I fetch results with ResultSet?

To fetch results using a ResultSet in Java JDBC, you follow a standard pattern of executing a query, iterating through the rows, and extracting data using “getter” methods.

Basic Fetching Pattern

  1. Execute the Query: Use stmt.executeQuery(sql) (for Statement) or pstmt.executeQuery() (for PreparedStatement).
  2. Iterate through Rows: Use a while (rs.next()) loop. The next() method moves the cursor to the next row and returns false when there are no more rows.
  3. Retrieve Values: Use methods like getString(), getInt(), getLong(), or getBigDecimal() based on the column type.

Example

try (Connection conn = DriverManager.getConnection(url, user, pass);
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery("SELECT id, name, created_at FROM users")) {

    while (rs.next()) {
        // 1. Fetch by Column Name (Recommended for readability)
        int id = rs.getInt("id");
        String name = rs.getString("name");

        // 2. Fetch by Column Index (1-based, often faster)
        java.sql.Timestamp date = rs.getTimestamp(3);

        System.out.println("User: " + id + " | Name: " + name);
    }
} catch (SQLException e) {
    e.printStackTrace();
}

Key Considerations

  • 1-Based Indexing: If you use column indices (e.g., rs.getString(1)), remember that JDBC indices start at 1, not 0.
  • Column Names vs. Indices:
    • Names: More maintainable and easier to read.
    • Indices: Slightly better performance as the driver doesn’t have to look up the column mapping.
  • Try-with-Resources: Always wrap the ResultSet in a try-with-resources block (or ensure it is closed in a finally block) to prevent database cursor leaks.
  • Data Types: Use the getter that matches your database schema:
    • rs.getInt() for INTEGER
    • rs.getString() for VARCHAR/TEXT
    • rs.getBigDecimal() for DECIMAL/NUMERIC (best for currency)
    • rs.getBoolean() for BIT/BOOLEAN
  • Handling Nulls: If a column allows NULL, primitive getters (like getInt) will return 0. To check if the value was actually null in the database, call rs.wasNull() immediately after the getter.