Java Database Connectivity (JDBC)

1. What is JDBC?

JDBC (Java Database Connectivity) is a standard Java API that defines how Java applications communicate with relational databases. It is part of the Java SE platform (java.sql and javax.sql packages).

Key characteristics:

  • Database-agnostic: The same Java code works with MySQL, PostgreSQL, Oracle, SQLite, and more — you only change the driver and connection URL.
  • Driver-based: Each database vendor provides a JDBC driver (a JAR file) that translates JDBC calls into database-specific wire protocol.
  • SQL-based: You write standard SQL — JDBC sends it to the database and returns results as Java objects.

When to use JDBC: JDBC is the foundation layer. Higher-level frameworks like Spring Data JPA and Hibernate are built on top of it. Understanding JDBC is essential for debugging, performance tuning, and situations where you need fine-grained SQL control.

2. JDBC Architecture

JDBC follows a layered architecture that decouples your application code from any specific database:

Layer Component Role
1 Java Application Your code — calls JDBC API interfaces.
2 JDBC API Interfaces: Connection, Statement, ResultSet, etc.
3 DriverManager Manages registered drivers; creates Connection objects.
4 JDBC Driver Vendor-specific JAR that translates JDBC calls to database protocol.
5 Database MySQL, PostgreSQL, Oracle, SQLite, etc.
// The 5-step JDBC workflow
// 1. Load the driver (automatic since JDBC 4.0)
// 2. Establish a connection
Connection conn = DriverManager.getConnection(url, user, pass);

// 3. Create a statement
Statement stmt = conn.createStatement();

// 4. Execute SQL and get results
ResultSet rs = stmt.executeQuery("SELECT * FROM employees");

// 5. Process results and close resources
while (rs.next()) { System.out.println(rs.getString("name")); }
rs.close(); stmt.close(); conn.close();

3. JDBC Driver Types

Type Name Description Use Today?
Type 1 JDBC-ODBC Bridge Converts JDBC calls to ODBC calls. Requires ODBC driver installed on client. No — removed in Java 8.
Type 2 Native-API Driver Uses database-specific native libraries. Requires native install on client. Rare.
Type 3 Network Protocol Driver Sends JDBC calls over network to a middleware server that talks to the DB. Rare.
Type 4 Thin Driver (Pure Java) Pure Java driver that communicates directly with the database using its native network protocol. No native libraries needed. Yes — standard choice.

In practice: Always use Type 4 drivers. MySQL Connector/J, PostgreSQL JDBC Driver, and Oracle Thin JDBC are all Type 4. Add the driver JAR to your classpath or declare it as a Maven/Gradle dependency.

4. Setting Up JDBC

Add the database driver to your project. With Maven, add to pom.xml:

<!-- MySQL -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.3.0</version>
</dependency>

<!-- PostgreSQL -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.3</version>
</dependency>

<!-- SQLite (great for learning, no server required) -->
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.45.1.0</version>
</dependency>

Since JDBC 4.0 (Java 6+), drivers are auto-loaded via the Service Provider mechanism — no Class.forName() is needed. For legacy code you may still see:

// Legacy: manually load driver class (not needed since JDBC 4.0)
Class.forName("com.mysql.cj.jdbc.Driver");      // MySQL
Class.forName("org.postgresql.Driver");          // PostgreSQL
Class.forName("oracle.jdbc.driver.OracleDriver"); // Oracle

5. Establishing a Connection

DriverManager.getConnection(url, user, password) creates a Connection object. The JDBC URL format varies by database:

import java.sql.*;

public class ConnectionDemo {
    // MySQL connection URL format:
    // jdbc:mysql://host:port/database?parameters
    static final String MYSQL_URL =
        "jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC";

    // PostgreSQL:
    // static final String PG_URL = "jdbc:postgresql://localhost:5432/mydb";

    // SQLite (file-based, no server needed):
    // static final String SQLITE_URL = "jdbc:sqlite:mydb.sqlite";

    public static void main(String[] args) {
        try (Connection conn = DriverManager.getConnection(
                MYSQL_URL, "root", "password")) {

            System.out.println("Connected to: " + conn.getMetaData().getURL());
            System.out.println("Driver:       "
                + conn.getMetaData().getDriverName());
            System.out.println("Auto-commit:  " + conn.getAutoCommit());
            System.out.println("DB Product:   "
                + conn.getMetaData().getDatabaseProductName() + " "
                + conn.getMetaData().getDatabaseProductVersion());

        } catch (SQLException e) {
            System.err.println("Connection failed!");
            System.err.println("SQL State: " + e.getSQLState());
            System.err.println("Error Code: " + e.getErrorCode());
            System.err.println("Message: " + e.getMessage());
        }
    }
}
Connected to: jdbc:mysql://localhost:3306/mydb
Driver: MySQL Connector/J
Auto-commit: true
DB Product: MySQL 8.0.35

6. Statement

Statement is used for executing static SQL queries with no parameters. Use executeQuery() for SELECT and executeUpdate() for INSERT, UPDATE, DELETE, and DDL.

import java.sql.*;

public class StatementDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/mydb";

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

            // DDL — create a table
            stmt.executeUpdate("""
                CREATE TABLE IF NOT EXISTS products (
                    id    INT AUTO_INCREMENT PRIMARY KEY,
                    name  VARCHAR(100) NOT NULL,
                    price DOUBLE NOT NULL
                )
            """);
            System.out.println("Table created.");

            // DML — insert rows
            int rows = stmt.executeUpdate(
                "INSERT INTO products (name, price) VALUES ('Laptop', 999.99)");
            System.out.println("Rows inserted: " + rows);

            stmt.executeUpdate(
                "INSERT INTO products (name, price) VALUES ('Mouse', 29.99)");

            // DQL — select rows
            ResultSet rs = stmt.executeQuery("SELECT * FROM products");
            while (rs.next()) {
                System.out.printf("ID: %d | Name: %-10s | Price: %.2f%n",
                    rs.getInt("id"), rs.getString("name"), rs.getDouble("price"));
            }
            rs.close();
        }
    }
}
Table created.
Rows inserted: 1
ID: 1 | Name: Laptop | Price: 999.99
ID: 2 | Name: Mouse | Price: 29.99

Security risk: Never concatenate user input into a Statement SQL string — this opens your app to SQL injection attacks. Always use PreparedStatement for any query with parameters.

7. PreparedStatement

PreparedStatement is pre-compiled SQL with placeholders (?) filled in at runtime. It is the correct tool for any query with parameters because:

  • Prevents SQL injection — parameters are always treated as data, never as SQL code.
  • Better performance — the query is compiled once and can be reused many times.
  • Cleaner code — no string concatenation of SQL fragments.
import java.sql.*;

public class PreparedStatementDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/mydb";

        try (Connection conn = DriverManager.getConnection(url, "root", "password")) {

            // INSERT with PreparedStatement
            String insertSQL = "INSERT INTO products (name, price) VALUES (?, ?)";
            try (PreparedStatement ps = conn.prepareStatement(insertSQL)) {
                ps.setString(1, "Keyboard"); // parameter index starts at 1
                ps.setDouble(2, 79.99);
                int rows = ps.executeUpdate();
                System.out.println("Inserted rows: " + rows);

                // Reuse the same statement with different values
                ps.setString(1, "Monitor");
                ps.setDouble(2, 349.99);
                ps.executeUpdate();

                ps.setString(1, "Webcam");
                ps.setDouble(2, 59.99);
                ps.executeUpdate();
                System.out.println("All products inserted.");
            }

            // SELECT with parameters
            String selectSQL = "SELECT * FROM products WHERE price < ? ORDER BY price";
            try (PreparedStatement ps = conn.prepareStatement(selectSQL)) {
                ps.setDouble(1, 200.00); // find products under $200

                try (ResultSet rs = ps.executeQuery()) {
                    System.out.println("\nProducts under $200:");
                    while (rs.next()) {
                        System.out.printf("  %-10s $%.2f%n",
                            rs.getString("name"), rs.getDouble("price"));
                    }
                }
            }

            // UPDATE with parameters
            String updateSQL = "UPDATE products SET price = ? WHERE name = ?";
            try (PreparedStatement ps = conn.prepareStatement(updateSQL)) {
                ps.setDouble(1, 89.99);
                ps.setString(2, "Keyboard");
                int updated = ps.executeUpdate();
                System.out.println("\nRows updated: " + updated);
            }
        }
    }
}
Inserted rows: 1
All products inserted.

Products under $200:
Webcam $59.99
Keyboard $79.99

Rows updated: 1

8. CallableStatement

CallableStatement executes stored procedures defined in the database. It supports IN parameters (input), OUT parameters (output), and INOUT parameters.

import java.sql.*;

public class CallableStatementDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/mydb";

        // Assume this stored procedure exists in MySQL:
        // CREATE PROCEDURE get_product_count(IN min_price DOUBLE, OUT total INT)
        // BEGIN
        //   SELECT COUNT(*) INTO total FROM products WHERE price >= min_price;
        // END

        try (Connection conn = DriverManager.getConnection(url, "root", "password")) {

            // Call stored procedure: {call procedure_name(?, ?)}
            try (CallableStatement cs = conn.prepareCall(
                    "{call get_product_count(?, ?)}")) {

                // Set IN parameter
                cs.setDouble(1, 50.00);

                // Register OUT parameter — specify SQL type
                cs.registerOutParameter(2, Types.INTEGER);

                cs.execute();

                // Retrieve the OUT parameter value
                int count = cs.getInt(2);
                System.out.println("Products with price >= $50: " + count);
            }

            // Stored procedure with only IN params that returns a ResultSet
            // CREATE PROCEDURE get_products_by_price(IN max_price DOUBLE)
            // BEGIN
            //   SELECT * FROM products WHERE price <= max_price;
            // END
            try (CallableStatement cs = conn.prepareCall(
                    "{call get_products_by_price(?)}")) {
                cs.setDouble(1, 100.00);
                try (ResultSet rs = cs.executeQuery()) {
                    while (rs.next()) {
                        System.out.println(rs.getString("name")
                            + ": $" + rs.getDouble("price"));
                    }
                }
            }
        }
    }
}

9. ResultSet

A ResultSet is a cursor pointing to the rows returned by a SELECT query. Initially it points before the first row. Call next() to advance one row at a time. Retrieve column values by name or index.

import java.sql.*;

public class ResultSetDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/mydb";

        try (Connection conn = DriverManager.getConnection(url, "root", "password");
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(
                 "SELECT id, name, price FROM products ORDER BY price DESC")) {

            System.out.println("=== Product Catalog ===");

            // Iterate all rows
            while (rs.next()) {
                int    id    = rs.getInt("id");        // by column name
                String name  = rs.getString(2);         // by column index (1-based)
                double price = rs.getDouble("price");

                // Check for SQL NULL
                if (rs.wasNull()) {
                    System.out.println("Price is NULL for: " + name);
                } else {
                    System.out.printf("%-3d  %-12s  $%8.2f%n", id, name, price);
                }
            }

            System.out.println("=======================");

            // Scrollable ResultSet — can move backwards
            Statement scrollStmt = conn.createStatement(
                ResultSet.TYPE_SCROLL_INSENSITIVE,
                ResultSet.CONCUR_READ_ONLY);
            ResultSet scrollRs = scrollStmt.executeQuery("SELECT * FROM products");

            scrollRs.last();  // move to last row
            System.out.println("Total rows: " + scrollRs.getRow());

            scrollRs.first(); // move back to first row
            System.out.println("First product: " + scrollRs.getString("name"));

            scrollRs.close();
            scrollStmt.close();
        }
    }
}
=== Product Catalog ===
1 Laptop $ 999.99
4 Monitor $ 349.99
3 Keyboard $ 89.99
5 Webcam $ 59.99
2 Mouse $ 29.99
=======================
Total rows: 5
First product: Laptop

10. CRUD Example

A complete Create, Read, Update, Delete example using PreparedStatement and try-with-resources throughout:

import java.sql.*;

public class CRUDDemo {
    static final String URL  = "jdbc:mysql://localhost:3306/mydb";
    static final String USER = "root";
    static final String PASS = "password";

    // CREATE
    public static int createEmployee(String name, String dept, double salary)
            throws SQLException {
        String sql = "INSERT INTO employees (name, department, salary) VALUES (?, ?, ?)";
        try (Connection conn = DriverManager.getConnection(URL, USER, PASS);
             PreparedStatement ps = conn.prepareStatement(sql,
                 Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, name);
            ps.setString(2, dept);
            ps.setDouble(3, salary);
            ps.executeUpdate();
            try (ResultSet keys = ps.getGeneratedKeys()) {
                if (keys.next()) {
                    int id = keys.getInt(1);
                    System.out.println("Created employee ID: " + id);
                    return id;
                }
            }
        }
        return -1;
    }

    // READ
    public static void readEmployees() throws SQLException {
        String sql = "SELECT * FROM employees ORDER BY id";
        try (Connection conn = DriverManager.getConnection(URL, USER, PASS);
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {
            System.out.println("\n--- All Employees ---");
            while (rs.next()) {
                System.out.printf("  [%d] %-15s %-12s $%.2f%n",
                    rs.getInt("id"), rs.getString("name"),
                    rs.getString("department"), rs.getDouble("salary"));
            }
        }
    }

    // UPDATE
    public static void updateSalary(int id, double newSalary) throws SQLException {
        String sql = "UPDATE employees SET salary = ? WHERE id = ?";
        try (Connection conn = DriverManager.getConnection(URL, USER, PASS);
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setDouble(1, newSalary);
            ps.setInt(2, id);
            int rows = ps.executeUpdate();
            System.out.println("Updated " + rows + " row(s) for ID: " + id);
        }
    }

    // DELETE
    public static void deleteEmployee(int id) throws SQLException {
        String sql = "DELETE FROM employees WHERE id = ?";
        try (Connection conn = DriverManager.getConnection(URL, USER, PASS);
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setInt(1, id);
            int rows = ps.executeUpdate();
            System.out.println("Deleted " + rows + " row(s) for ID: " + id);
        }
    }

    public static void main(String[] args) throws SQLException {
        int id1 = createEmployee("Alice Smith", "Engineering", 95000.0);
        int id2 = createEmployee("Bob Jones",  "Marketing",   72000.0);
        int id3 = createEmployee("Carol White","Engineering", 88000.0);

        readEmployees();

        updateSalary(id1, 102000.0);
        readEmployees();

        deleteEmployee(id3);
        readEmployees();
    }
}
Created employee ID: 1
Created employee ID: 2
Created employee ID: 3

--- All Employees ---
[1] Alice Smith Engineering $95000.00
[2] Bob Jones Marketing $72000.00
[3] Carol White Engineering $88000.00

Updated 1 row(s) for ID: 1
Deleted 1 row(s) for ID: 3

11. Transactions

By default, JDBC operates in auto-commit mode — every statement is committed immediately. For operations that must succeed or fail together (e.g., transferring money between accounts), disable auto-commit and manage the transaction manually.

import java.sql.*;

public class TransactionDemo {
    public static void transferFunds(Connection conn, int fromId,
                                     int toId, double amount)
            throws SQLException {
        conn.setAutoCommit(false); // begin transaction

        try {
            // Step 1: Debit from source account
            String debit = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
            try (PreparedStatement ps = conn.prepareStatement(debit)) {
                ps.setDouble(1, amount);
                ps.setInt(2, fromId);
                int rows = ps.executeUpdate();
                if (rows == 0) throw new SQLException("Source account not found: " + fromId);
            }

            // Step 2: Check for sufficient funds
            String check = "SELECT balance FROM accounts WHERE id = ?";
            try (PreparedStatement ps = conn.prepareStatement(check)) {
                ps.setInt(1, fromId);
                try (ResultSet rs = ps.executeQuery()) {
                    if (rs.next() && rs.getDouble("balance") < 0) {
                        throw new SQLException("Insufficient funds in account: " + fromId);
                    }
                }
            }

            // Step 3: Credit to destination account
            String credit = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
            try (PreparedStatement ps = conn.prepareStatement(credit)) {
                ps.setDouble(1, amount);
                ps.setInt(2, toId);
                int rows = ps.executeUpdate();
                if (rows == 0) throw new SQLException("Destination account not found: " + toId);
            }

            conn.commit(); // ALL steps succeeded — commit
            System.out.println("Transfer of $" + amount + " completed successfully.");

        } catch (SQLException e) {
            conn.rollback(); // ANY step failed — rollback everything
            System.err.println("Transfer failed! Transaction rolled back: " + e.getMessage());
            throw e; // re-throw so caller knows it failed
        } finally {
            conn.setAutoCommit(true); // restore default behavior
        }
    }

    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/mydb";
        try (Connection conn = DriverManager.getConnection(url, "root", "password")) {
            transferFunds(conn, 1, 2, 500.00); // transfer $500 from account 1 to 2
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

12. ResultSetMetaData

ResultSetMetaData describes the structure of a ResultSet — number of columns, their names, types, and more. Useful for building generic database utilities that work with any table.

import java.sql.*;

public class MetaDataDemo {
    public static void printTable(Connection conn, String tableName)
            throws SQLException {
        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(
                 "SELECT * FROM " + tableName + " LIMIT 10")) {

            ResultSetMetaData meta = rs.getMetaData();
            int colCount = meta.getColumnCount();

            System.out.println("Table: " + tableName);
            System.out.println("Columns: " + colCount);
            System.out.println();

            // Print column headers dynamically
            for (int i = 1; i <= colCount; i++) {
                System.out.printf("%-15s", meta.getColumnName(i));
                System.out.println("  [" + meta.getColumnTypeName(i)
                    + "(" + meta.getColumnDisplaySize(i) + ")]");
            }

            System.out.println("---");

            // Print data rows
            while (rs.next()) {
                for (int i = 1; i <= colCount; i++) {
                    System.out.printf("%-15s", rs.getString(i));
                }
                System.out.println();
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/mydb";
        try (Connection conn = DriverManager.getConnection(url, "root", "password")) {
            printTable(conn, "products");
        }
    }
}
Table: products
Columns: 3

id [INT(11)]
name [VARCHAR(100)]
price [DOUBLE(22)]
---
1 Laptop 999.99
2 Mouse 29.99

13. Connection Pooling

Opening a raw DriverManager connection for every request is expensive — it involves a TCP handshake, authentication, and setup on each call. In production applications, a connection pool maintains a set of pre-opened connections that are reused, dramatically improving throughput.

HikariCP is the fastest, most widely used connection pool for Java. Add the dependency:

<!-- Maven -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>5.1.0</version>
</dependency>
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.*;

public class ConnectionPoolDemo {
    private static HikariDataSource dataSource;

    static {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        config.setUsername("root");
        config.setPassword("password");

        // Pool settings
        config.setMaximumPoolSize(10);       // max 10 concurrent connections
        config.setMinimumIdle(2);            // keep 2 idle connections ready
        config.setConnectionTimeout(30000);  // wait up to 30s for a connection
        config.setIdleTimeout(600000);       // close idle after 10 min
        config.setMaxLifetime(1800000);      // retire connections after 30 min
        config.setPoolName("MyAppPool");

        dataSource = new HikariDataSource(config);
        System.out.println("Connection pool initialized.");
    }

    public static void queryWithPool() throws SQLException {
        // getConnection() borrows from pool; close() returns it
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                 "SELECT COUNT(*) AS total FROM products");
             ResultSet rs = ps.executeQuery()) {
            if (rs.next()) {
                System.out.println("Total products: " + rs.getInt("total"));
            }
        }
        // Connection is returned to pool here — NOT closed
    }

    public static void main(String[] args) throws Exception {
        // Simulate multiple concurrent requests reusing pool connections
        for (int i = 0; i < 5; i++) {
            queryWithPool();
        }
        dataSource.close(); // shutdown pool on app exit
    }
}

DataSource vs DriverManager: Use DataSource (from a pool) in all production code. DriverManager.getConnection() is fine for learning and small scripts but creates a new physical connection every time it is called.

14. JDBC Best Practices

  • Always use PreparedStatement — never concatenate user input into SQL strings. This is the single most important rule for security.
  • Always use try-with-resourcesConnection, Statement, and ResultSet all implement AutoCloseable. Failing to close them causes connection leaks and crashes in production.
  • Use a connection pool — never call DriverManager.getConnection() on every request in production. Use HikariCP, Apache DBCP, or c3p0.
  • Handle SQLException properly — log getSQLState() and getErrorCode() alongside the message for meaningful diagnostics.
  • Use transactions for multi-step operations — set autoCommit(false), commit() on success, and rollback() in the catch block.
  • Never store credentials in source code — use environment variables, a secrets manager, or an application config file outside version control.
  • Use batch updates for bulk insertsaddBatch() + executeBatch() sends multiple rows in one round trip, massively reducing latency.
// Batch insert example
String sql = "INSERT INTO logs (level, message) VALUES (?, ?)";
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    conn.setAutoCommit(false);

    for (int i = 0; i < 10000; i++) {
        ps.setString(1, "INFO");
        ps.setString(2, "Log message " + i);
        ps.addBatch(); // queue the row

        if (i % 500 == 0) {        // flush every 500 rows
            ps.executeBatch();
            ps.clearBatch();
        }
    }
    ps.executeBatch(); // flush remaining rows
    conn.commit();
    System.out.println("10,000 rows inserted in batch.");
}
10,000 rows inserted in batch.

Continue learning