SQL Database

Why SQL Databases Matter

SQL databases are the backbone of most enterprise applications, storing business-critical data with ACID guarantees. Java provides robust database integration through JDBC and higher-level abstractions.

Core Benefits:

  • Data persistence: Store application state reliably
  • ACID transactions: Ensure data consistency
  • Complex queries: Join and aggregate data efficiently
  • Concurrent access: Multiple users/processes safely
  • Data integrity: Constraints and foreign keys

Problem: Raw JDBC is verbose, error-prone (resource leaks, SQL injection), and requires manual transaction management.

Solution: Use connection pooling, prepared statements, and modern libraries for safe, efficient database access.

Database Access Layers

ApproachAbstraction LevelProsConsUse When
Raw JDBCLowFull control, no dependenciesVerbose, error-proneSimple queries, learning
HikariCP + JDBCLow + PoolingFast, production-readyStill manual SQLPerformance-critical
Spring JdbcTemplateMediumLess verbose, safeSpring dependencySpring applications
jOOQMediumType-safe SQL, DSLLearning curveComplex queries
JPA/HibernateHighORM, portablePerformance overhead, complexityDomain-driven design

Recommendation: Start with JDBC + HikariCP, move to JPA for complex domain models.

JDBC Fundamentals

JDBC (Java Database Connectivity) is the standard API for database access in Java.

Establishing Connections

Pattern (without pooling):

String url = "jdbc:postgresql://localhost:5432/mydb";  // => JDBC connection URL
                                                         // => Format: jdbc:<database>://<host>:<port>/<database-name>
                                                         // => postgresql is database type, localhost:5432 is server
String user = "dbuser";  // => Database username
String password = "dbpass";  // => Database password (NEVER hardcode in production - use environment variables!)

try (Connection conn = DriverManager.getConnection(url, user, password)) {  // => Establishes TCP connection to database
                                                                             // => Authenticates with username/password
                                                                             // => Connection is "conn" object
                                                                             // => try-with-resources auto-closes connection
    // Use connection  // => Execute queries, inserts, updates, deletes
} catch (SQLException e) {  // => Catches database errors (connection failed, authentication failed)
    System.err.println("Connection error: " + e.getMessage());  // => Prints error message
}  // => Connection automatically closed here (try-with-resources)

Problem: Creating connections is expensive (TCP handshake, authentication). Creating per-query is inefficient.

Solution: Use connection pooling (see HikariCP section).

Executing Queries

SELECT query:

String sql = "SELECT id, name, email FROM users WHERE active = true";

try (Connection conn = getConnection();
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {

    while (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        String email = rs.getString("email");

        System.out.printf("User %d: %s (%s)%n", id, name, email);
    }
} catch (SQLException e) {
    System.err.println("Query error: " + e.getMessage());
}

INSERT/UPDATE/DELETE:

String sql = "INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')";

try (Connection conn = getConnection();
     Statement stmt = conn.createStatement()) {

    int rowsAffected = stmt.executeUpdate(sql);
    System.out.println("Inserted " + rowsAffected + " row(s)");
} catch (SQLException e) {
    System.err.println("Insert error: " + e.getMessage());
}

Prepared Statements (SQL Injection Prevention)

CRITICAL: Never concatenate user input into SQL - it enables SQL injection attacks.

Vulnerable code (SQL injection risk):

// DANGEROUS - SQL INJECTION VULNERABILITY
String username = request.getParameter("username");  // => Gets username from HTTP request (user-controlled input!)
String sql = "SELECT * FROM users WHERE username = '" + username + "'";  // => String concatenation creates SQL
                                                                          // => If username = "alice", SQL is: SELECT * FROM users WHERE username = 'alice'
                                                                          // => If username = "' OR '1'='1", SQL is: SELECT * FROM users WHERE username = '' OR '1'='1'
                                                                          // => Attack SQL returns ALL users (bypasses authentication!)
// Attack: username = "' OR '1'='1"  // => Attacker closes quote, adds OR condition always true
                                     // => '1'='1' is always true → query returns all rows
                                     // => Can also use: "; DROP TABLE users; --" to delete table

Safe code (parameterized):

String sql = "SELECT * FROM users WHERE username = ?";  // => Parameterized query with ? placeholder
                                                          // => ? is parameter marker (NOT string concatenation)
                                                          // => Database treats parameter as DATA, never as SQL code

try (Connection conn = getConnection();  // => Gets connection from pool or DriverManager
     PreparedStatement stmt = conn.prepareStatement(sql)) {  // => Creates PreparedStatement (compiles SQL once)
                                                              // => PreparedStatement is reusable, efficient

    stmt.setString(1, username);  // Parameter binding prevents injection  // => Binds username to parameter 1 (first ?)
                                                                            // => Parameter index starts at 1 (NOT 0!)
                                                                            // => Database escapes special characters automatically
                                                                            // => If username = "' OR '1'='1", treated as LITERAL string (not SQL)
                                                                            // => Attack SQL becomes: SELECT * FROM users WHERE username = '\' OR \'1\'=\'1\''
                                                                            // => Returns zero rows (safe!)

    try (ResultSet rs = stmt.executeQuery()) {  // => Executes prepared statement
                                                 // => Returns ResultSet with query results
        while (rs.next()) {  // => Iterates through result rows
            // Process results  // => Extract columns with rs.getString(), rs.getInt(), etc.
        }
    }  // => ResultSet auto-closed
} catch (SQLException e) {  // => Catches database errors
    System.err.println("Query error: " + e.getMessage());
}  // => PreparedStatement and Connection auto-closed

Type-safe parameter binding:

stmt.setString(1, "Alice");           // String
stmt.setInt(2, 30);                   // Integer
stmt.setTimestamp(3, timestamp);      // Timestamp
stmt.setBigDecimal(4, amount);        // BigDecimal (for money)

Problem: String concatenation allows SQL injection attacks.

Solution: Always use PreparedStatement with parameter binding.

Getting Generated Keys

Retrieve auto-generated IDs after INSERT.

Pattern:

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

try (Connection conn = getConnection();
     PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

    stmt.setString(1, "Alice");
    stmt.setString(2, "alice@example.com");

    int rowsAffected = stmt.executeUpdate();

    try (ResultSet rs = stmt.getGeneratedKeys()) {
        if (rs.next()) {
            long id = rs.getLong(1);
            System.out.println("Inserted user with ID: " + id);
        }
    }
} catch (SQLException e) {
    System.err.println("Insert error: " + e.getMessage());
}

Connection Pooling with HikariCP

HikariCP is the fastest, most reliable connection pool for Java.

Why Connection Pooling

Without pooling:

  • Create connection: ~50-100ms (TCP handshake, auth)
  • Execute query: ~10ms
  • Close connection: ~10ms
  • Total: ~70-120ms per query

With pooling:

  • Get connection from pool: ~1ms
  • Execute query: ~10ms
  • Return to pool: ~1ms
  • Total: ~12ms per query (6-10x faster)

HikariCP Configuration

Maven dependency:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.2.1</version>
</dependency>

Pattern:

import com.zaxxer.hikari.*;

HikariConfig config = new HikariConfig();  // => Creates configuration object
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");  // => Database connection URL
config.setUsername("dbuser");  // => Database username
config.setPassword("dbpass");  // => Database password (use environment variables in production!)

// Pool configuration
config.setMaximumPoolSize(10);              // Max connections  // => Maximum 10 connections in pool
                                                                 // => Requests wait if all connections busy
                                                                 // => Too many connections waste memory, too few causes contention
config.setMinimumIdle(2);                   // Min idle connections  // => Keeps 2 connections ready (minimizes wait time)
                                                                       // => Pool grows from 2 to 10 based on demand
config.setConnectionTimeout(30000);          // 30s connection timeout  // => Wait max 30 seconds for connection before throwing exception
                                                                         // => Prevents infinite waiting if pool exhausted
config.setIdleTimeout(600000);              // 10min idle timeout  // => Idle connections closed after 10 minutes (saves resources)
                                                                    // => Pool shrinks back to minimumIdle when idle
config.setMaxLifetime(1800000);             // 30min max lifetime  // => Connections recycled after 30 minutes (prevents stale connections)
                                                                    // => Database may close connections after idle time - this prevents errors

HikariDataSource dataSource = new HikariDataSource(config);  // => Creates connection pool
                                                              // => Opens minimum idle connections immediately
                                                              // => Pool ready for requests

// Use connections
try (Connection conn = dataSource.getConnection()) {  // => Gets connection from pool (fast: ~1ms vs ~50-100ms without pooling)
                                                       // => Waits if all connections busy (up to connectionTimeout)
    // Execute queries  // => Use connection for database operations
}  // => try-with-resources returns connection to pool (NOT closed, reused!)
   // => Connection immediately available for next request

// Shutdown pool
dataSource.close();  // => Closes all connections in pool
                     // => Call on application shutdown ONLY (not per request!)

Recommended pool sizes:

  • Web apps: connections = (core_count * 2) + effective_spindle_count
  • Example (4 cores, 1 disk): 10 connections
  • Rule: Start small (10), measure, increase only if needed

Problem: Creating connections per request is slow and wasteful.

Solution: HikariCP reuses connections, dramatically improving performance.

Transaction Management

Transactions ensure data consistency with ACID properties (Atomicity, Consistency, Isolation, Durability).

Manual Transaction Control

Pattern:

Connection conn = null;  // => Declare outside try for finally access
try {
    conn = dataSource.getConnection();  // => Gets connection from pool
    conn.setAutoCommit(false);  // Start transaction  // => Disables auto-commit (default: each statement commits immediately)
                                                        // => Transaction starts - changes not visible until commit()

    // Multiple operations in transaction
    updateInventory(conn, productId, -quantity);  // => UPDATE inventory SET quantity = quantity - ? WHERE product_id = ?
                                                   // => Deducts quantity from inventory
    createOrder(conn, userId, productId, quantity);  // => INSERT INTO orders (user_id, product_id, quantity) VALUES (?, ?, ?)
                                                      // => Creates order record
    chargePayment(conn, userId, amount);  // => INSERT INTO payments (user_id, amount) VALUES (?, ?)
                                          // => Charges customer
                                          // => All three operations in SAME transaction - atomic

    conn.commit();  // Commit if all succeed  // => Makes all changes permanent
                                               // => Other connections can see changes
                                               // => Releases locks
} catch (SQLException e) {  // => Catches ANY database error in transaction
    if (conn != null) {  // => Check connection not null
        try {
            conn.rollback();  // Rollback on error  // => Undoes ALL changes in transaction
                                                     // => Inventory NOT deducted, order NOT created, payment NOT charged
                                                     // => Database returns to state before transaction
                                                     // => CRITICAL: All-or-nothing guarantee
        } catch (SQLException ex) {  // => Rollback can also fail (connection lost)
            ex.printStackTrace();
        }
    }
    throw new RuntimeException("Transaction failed", e);  // => Re-throw to caller
} finally {  // => Always executes (success or failure)
    if (conn != null) {
        try {
            conn.setAutoCommit(true);  // Restore auto-commit  // => Re-enables auto-commit for connection reuse
            conn.close();  // => Returns connection to pool (doesn't actually close)
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Transaction Isolation Levels

Control concurrent transaction behavior.

Isolation levels (from weakest to strongest):

  • READ_UNCOMMITTED: Dirty reads possible
  • READ_COMMITTED: Default (PostgreSQL), prevents dirty reads
  • REPEATABLE_READ: Prevents non-repeatable reads
  • SERIALIZABLE: Strictest, prevents phantom reads

Pattern:

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

Problem: Default isolation may not match business requirements.

Solution: Choose appropriate isolation level based on consistency needs vs. performance.

Savepoints

Create savepoints for partial rollback within transactions.

Pattern:

conn.setAutoCommit(false);

Savepoint savepoint1 = conn.setSavepoint("beforeRiskyOperation");

try {
    riskyOperation(conn);
} catch (SQLException e) {
    conn.rollback(savepoint1);  // Rollback to savepoint
}

conn.commit();

ResultSet Handling

Extracting Data

Type-safe extraction:

while (rs.next()) {
    long id = rs.getLong("id");
    String name = rs.getString("name");
    LocalDate birthDate = rs.getObject("birth_date", LocalDate.class);
    BigDecimal balance = rs.getBigDecimal("balance");

    User user = new User(id, name, birthDate, balance);
}

Null handling:

String email = rs.getString("email");
if (rs.wasNull()) {
    email = "no-email@example.com";  // Default for null
}

Mapping to Objects

Pattern (manual mapping):

public User mapUser(ResultSet rs) throws SQLException {
    return new User(
        rs.getLong("id"),
        rs.getString("name"),
        rs.getString("email"),
        rs.getTimestamp("created_at").toLocalDateTime()
    );
}

List<User> users = new ArrayList<>();
while (rs.next()) {
    users.add(mapUser(rs));
}

Batch Operations

Execute multiple statements efficiently in one database round-trip.

Pattern:

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

try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(sql)) {

    conn.setAutoCommit(false);

    for (User user : users) {
        stmt.setString(1, user.getName());
        stmt.setString(2, user.getEmail());
        stmt.addBatch();

        if (users.indexOf(user) % 100 == 0) {
            stmt.executeBatch();  // Execute every 100 rows
        }
    }

    stmt.executeBatch();  // Execute remaining
    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    throw e;
}

Performance:

  • Individual inserts: 1000 inserts = 1000 round-trips (~10s)
  • Batch inserts: 1000 inserts = 10 batches (~1s, 10x faster)

Database Migrations

Manage schema changes with version-controlled migration scripts.

Flyway

Flyway applies SQL migrations in order, tracking applied versions.

Maven dependency:

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
    <version>11.1.0</version>
</dependency>

Migration files (src/main/resources/db/migration):

  • V1__create_users_table.sql
  • V2__add_email_column.sql
  • V3__create_orders_table.sql

V1__create_users_table.sql:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Java code:

import org.flywaydb.core.Flyway;

Flyway flyway = Flyway.configure()
    .dataSource(dataSource)
    .load();

flyway.migrate();  // Apply pending migrations

Problem: Manual schema changes cause inconsistency across environments.

Solution: Flyway automates migrations, ensuring all environments have same schema version.

Liquibase Alternative

Liquibase supports XML/YAML/JSON in addition to SQL.

changelog.xml:

<changeSet id="1" author="alice">
    <createTable tableName="users">
        <column name="id" type="bigint" autoIncrement="true">
            <constraints primaryKey="true"/>
        </column>
        <column name="name" type="varchar(255)">
            <constraints nullable="false"/>
        </column>
    </createTable>
</changeSet>

JPA and Hibernate Basics

JPA (Java Persistence API) provides ORM (Object-Relational Mapping) for automatic database mapping.

Entity Mapping

Pattern:

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true)
    private String email;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Getters/setters
}

EntityManager Operations

Pattern:

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp");
EntityManager em = emf.createEntityManager();

// Begin transaction
em.getTransaction().begin();

// Create
User user = new User();
user.setName("Alice");
user.setEmail("alice@example.com");
em.persist(user);

// Read
User found = em.find(User.class, 1L);

// Update
found.setEmail("newemail@example.com");
em.merge(found);

// Delete
em.remove(found);

// Commit transaction
em.getTransaction().commit();

em.close();
emf.close();

JPQL Queries

Pattern:

TypedQuery<User> query = em.createQuery(
    "SELECT u FROM User u WHERE u.email LIKE :pattern",
    User.class
);
query.setParameter("pattern", "%@example.com");

List<User> users = query.getResultList();

Problem: JPA simplifies CRUD but can hide performance issues (N+1 queries).

Solution: Use JPA for simple CRUD, optimize with JPQL/native queries for complex operations.

Testing with Databases

In-Memory H2 Database

H2 provides fast in-memory database for testing.

Maven dependency:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.3.232</version>
    <scope>test</scope>
</dependency>

Pattern:

@Test
void testUserRepository() {
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl("jdbc:h2:mem:test");
    config.setUsername("sa");
    config.setPassword("");

    HikariDataSource dataSource = new HikariDataSource(config);

    // Run migrations
    Flyway.configure()
        .dataSource(dataSource)
        .load()
        .migrate();

    // Test database operations
    UserRepository repo = new UserRepository(dataSource);
    User user = repo.create("Alice", "alice@example.com");

    assertThat(user.getId()).isNotNull();
    assertThat(repo.findById(user.getId())).isPresent();

    dataSource.close();
}

TestContainers

TestContainers runs real databases in Docker for integration testing.

Maven dependency:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>

Pattern:

import org.testcontainers.containers.PostgreSQLContainer;
import org.junit.jupiter.api.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserRepositoryIntegrationTest {

    private PostgreSQLContainer<?> postgres;
    private HikariDataSource dataSource;

    @BeforeAll
    void setUp() {
        postgres = new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("test")
            .withUsername("test")
            .withPassword("test");

        postgres.start();

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(postgres.getJdbcUrl());
        config.setUsername(postgres.getUsername());
        config.setPassword(postgres.getPassword());

        dataSource = new HikariDataSource(config);

        // Run migrations
        Flyway.configure()
            .dataSource(dataSource)
            .load()
            .migrate();
    }

    @AfterAll
    void tearDown() {
        dataSource.close();
        postgres.stop();
    }

    @Test
    void testCreateUser() {
        UserRepository repo = new UserRepository(dataSource);
        User user = repo.create("Alice", "alice@example.com");

        assertThat(user.getId()).isNotNull();
    }
}

Problem: H2 in-memory differs from production database (PostgreSQL, MySQL).

Solution: TestContainers runs real database in Docker for accurate integration tests.

Best Practices

1. Always Use Prepared Statements

Prevent SQL injection by never concatenating user input into SQL.

Before: "SELECT * FROM users WHERE name = '" + name + "'" After: PreparedStatement with parameter binding

2. Close Resources in Try-With-Resources

Prevent connection leaks by using try-with-resources for auto-closing.

Pattern:

try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(sql);
     ResultSet rs = stmt.executeQuery()) {
    // Use resources
}  // Automatically closed

3. Use Connection Pooling

Never create connections directly - always use pooling.

Before: DriverManager.getConnection() per request After: HikariCP connection pool

4. Handle NULL Values

SQL NULL is not the same as Java null.

Pattern:

BigDecimal balance = rs.getBigDecimal("balance");
if (rs.wasNull()) {
    balance = BigDecimal.ZERO;
}

5. Use BigDecimal for Money

Never use float/double for financial calculations.

Before: double amount = rs.getDouble("amount") After: BigDecimal amount = rs.getBigDecimal("amount")

6. Index Frequently Queried Columns

Add database indexes for WHERE/JOIN columns.

SQL:

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);

7. Batch Large Operations

Use batch operations for bulk inserts/updates.

Before: 1000 individual inserts (~10s) After: Batch of 100 per execute (~1s)

Common Patterns

Repository Pattern

Pattern:

public class UserRepository {
    private final DataSource dataSource;

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

    public Optional<User> findById(Long id) {
        String sql = "SELECT id, name, email FROM users WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {

            stmt.setLong(1, id);

            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapUser(rs));
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error finding user", e);
        }

        return Optional.empty();
    }

    public User create(String name, String email) {
        String sql = "INSERT INTO users (name, email) VALUES (?, ?) RETURNING id";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {

            stmt.setString(1, name);
            stmt.setString(2, email);

            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    long id = rs.getLong("id");
                    return new User(id, name, email);
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error creating user", e);
        }

        throw new RuntimeException("Failed to create user");
    }

    private User mapUser(ResultSet rs) throws SQLException {
        return new User(
            rs.getLong("id"),
            rs.getString("name"),
            rs.getString("email")
        );
    }
}

Related Content

Core Java Topics

External Resources

JDBC & Connection Pooling:

Database Migrations:

ORM:

Testing:


Last Updated: 2026-02-03 Java Version: 17+ (baseline), 21+ (recommended) Framework Versions: HikariCP 6.2.1, Flyway 11.1.0, Hibernate 6.6.5, TestContainers 1.20.4

Last updated