Java Fundamentals Tutorial 0/33 lessons ~6 min read Lesson 23

    SQL for Java Developers

    Spring Data JPA hides SQL — until production latency spikes, the read replica falls over, or a reconciliation job finds missing rows.

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Introduction

    Spring Data JPA hides SQL — until production latency spikes, the read replica falls over, or a reconciliation job finds missing rows. Every Java backend engineer must read EXPLAIN plans, design indexes, write correct JOINs, and understand transaction isolation — because Hibernate generates SQL from your entity graph, and bad SQL becomes bad Java at scale.

    This lesson covers SQL from the Java developer's perspective: relational joins (INNER, LEFT, correlated subqueries), index strategies (B-tree, composite, covering), ACID transactions and isolation levels, and query optimization patterns that appear daily in payment ledgers, order systems, and reporting pipelines.

    You don't need to be a DBA — but you must speak SQL fluently enough to review Hibernate logs, pair with DBAs on index design, and debug "works in dev, slow in prod" incidents.

    Business problem

    SQL ignorance causes production incidents:

    • Full table scans: Missing index on account_id — 50M row transfer table, 30s queries, connection pool exhausted.
    • Phantom reads: Report totals wrong because READ UNCOMMITTED or wrong isolation in long report query.
    • Cartesian products: JOIN missing ON clause in native query — returns billions of rows, OOM in app server.
    • Lock contention: UPDATE without index — locks entire table during batch settlement.
    • N+1 at SQL layer: ORM generates 1001 queries — fix is often SQL join or batch fetch, not more RAM.

    Why this topic exists

    SQL is the persistence contract beneath JPA:

    • Relational model: Tables, keys, constraints — Java objects map to rows, not the other way around.
    • JOINs express relationships: Account → Transfer → LedgerEntry — SQL traverses graph efficiently with planner-chosen plans.
    • Indexes make queries fast: B-tree lookups O(log n) vs sequential scan O(n) — difference between 5ms and 5min.
    • Transactions guarantee correctness: Debit + credit atomic — partial writes are financial bugs.
    • Optimization is measurable: EXPLAIN ANALYZE — evidence-based tuning, not guesswork.

    Core concepts

    Five SQL pillars for Java developers:

    • JOINs: INNER (matching rows only), LEFT (preserve left table), self-join, EXISTS vs IN for subqueries.
    • Indexes: Primary key, unique, composite (a,b), partial (WHERE status='ACTIVE') — match WHERE and JOIN columns.
    • Transactions: BEGIN … COMMIT/ROLLBACK — ACID. Spring @Transactional maps to JDBC transaction.
    • Isolation levels: READ COMMITTED (default PostgreSQL), REPEATABLE READ, SERIALIZABLE — trade consistency vs concurrency.
    • Query optimization: EXPLAIN, avoid SELECT *, limit columns, pagination, avoid functions on indexed columns.

    Internal architecture

    SQL execution path from Java to disk:

    text
    TransferService (@Transactional)
    Hibernate / JdbcTemplate
    JDBC Driver → PostgreSQL
    Query Parser → Optimizer (cost-based)
    ├── Index Scan on transfers(account_id) ← fast
    └── Seq Scan on transfers ← slow (no index)
    Transaction timeline (READ COMMITTED):
    T1: BEGIN → UPDATE accounts SET balance=... → (row lock)
    T2: BEGIN → SELECT balance → sees committed value only
    T1: COMMIT → releases lock
    T2: UPDATE may block until T1 commits
    Index design for: SELECT * FROM transfers WHERE account_id = ? AND created_at > ?
    CREATE INDEX idx_transfers_account_created ON transfers(account_id, created_at DESC);

    Joins, indexes, transactions, and query plans:

    INNER JOIN — accounts + transfers
    accounts
    Left table
    JOIN ON id
    Match key
    transfers
    Right table
    Result rows
    Matched only
    INNER JOIN returns rows where join condition satisfied.
    B-tree index lookup
    Query
    WHERE account_id=?
    B-tree index
    O(log n)
    Heap rows
    Fetch data
    Seq scan
    Avoid if large
    Index on filter column — planner chooses Index Scan.
    Transaction ACID
    BEGIN
    Start TX
    Debit + Credit
    Atomic unit
    COMMIT
    Persist
    ROLLBACK
    On failure
    Both writes succeed or neither — no partial transfer.
    EXPLAIN optimization loop
    Slow query
    Logs / APM
    EXPLAIN ANALYZE
    See plan
    Add index / rewrite
    Fix
    Verify
    Compare cost
    Measure before and after — optimization is empirical.

    Code walkthrough

    SQL patterns — joins, indexes, transactions, optimization:

    • Composite index: Column order matters — (from_account_id, created_at) serves WHERE + ORDER BY.
    • INNER vs LEFT: INNER for required match; LEFT when preserving rows from one side.
    • CHECK constraints: DB-enforced invariants — balance never negative even if app bug.
    • EXPLAIN ANALYZE: Actual execution time — not just estimated plan.
    java
    -- Schema (PostgreSQL)
    CREATE TABLE accounts (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    balance_cents BIGINT NOT NULL CHECK (balance_cents >= 0),
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
    );
    CREATE TABLE transfers (
    id UUID PRIMARY KEY,
    from_account_id UUID NOT NULL REFERENCES accounts(id),
    to_account_id UUID NOT NULL REFERENCES accounts(id),
    amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    -- Composite index for common query pattern
    CREATE INDEX idx_transfers_from_created
    ON transfers(from_account_id, created_at DESC);
    -- INNER JOIN: transfers with account names
    SELECT t.id, t.amount_cents, a_from.balance_cents AS from_balance
    FROM transfers t
    INNER JOIN accounts a_from ON t.from_account_id = a_from.id
    WHERE t.from_account_id = '550e8400-e29b-41d4-a716-446655440000'
    AND t.created_at >= now() - interval '30 days'
    ORDER BY t.created_at DESC
    LIMIT 50;
    -- LEFT JOIN: all accounts even without transfers
    SELECT a.id, COUNT(t.id) AS transfer_count
    FROM accounts a
    LEFT JOIN transfers t ON a.id = t.from_account_id
    WHERE a.status = 'ACTIVE'
    GROUP BY a.id;
    -- Transaction (JDBC)
    -- connection.setAutoCommit(false);
    -- try { debit(); credit(); connection.commit(); }
    -- catch { connection.rollback(); }
    -- EXPLAIN ANALYZE
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT * FROM transfers WHERE from_account_id = '550e8400-e29b-41d4-a716-446655440000';

    Production example

    Production SQL — Spring @Transactional + native query optimization:

    • readOnly=true: Hibernate optimization hint — no flush, connection read-only where supported.
    • JdbcTemplate for reports: Complex aggregations clearer in SQL than JPQL.
    • FOR UPDATE: Row lock during transfer — prevents concurrent double-spend.
    • CREATE INDEX CONCURRENTLY: PostgreSQL — no table lock during index build in prod.
    java
    @Service
    @RequiredArgsConstructor
    public class TransferReportService {
    private final JdbcTemplate jdbc;
    @Transactional(readOnly = true)
    public List<TransferSummary> dailySummary(LocalDate date) {
    String sql = """
    SELECT t.from_account_id, COUNT(*) AS cnt, SUM(t.amount_cents) AS total
    FROM transfers t
    WHERE t.created_at >= ? AND t.created_at < ?
    AND t.status = 'COMPLETED'
    GROUP BY t.from_account_id
    HAVING SUM(t.amount_cents) > ?
    """;
    return jdbc.query(sql,
    ps -> {
    ps.setTimestamp(1, Timestamp.valueOf(date.atStartOfDay()));
    ps.setTimestamp(2, Timestamp.valueOf(date.plusDays(1).atStartOfDay()));
    ps.setLong(3, 1_000_000L);
    },
    (rs, row) -> new TransferSummary(
    rs.getObject("from_account_id", UUID.class),
    rs.getLong("cnt"), rs.getLong("total")));
    }
    }
    // Pessimistic lock — native SQL FOR UPDATE
    @Query(value = "SELECT * FROM accounts WHERE id = :id FOR UPDATE", nativeQuery = true)
    Optional<AccountEntity> findByIdForUpdate(@Param("id") UUID id);
    // Flyway migration — index in versioned script
    -- V003__transfers_report_index.sql
    CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transfers_status_created
    ON transfers(status, created_at) WHERE status = 'COMPLETED';

    Enterprise case study

    E-commerce order report outage: A Black Friday dashboard query JOINed orders, order_items, products, and customers without selective WHERE — 400M row intermediate result. Missing composite index on orders(created_at, status). Fix: add partial index WHERE status IN ('PAID','SHIPPED'), rewrite to filter orders first in subquery, paginate results. Secondary: long-running report used default isolation — phantom rows caused totals to shift mid-export; switched report to REPEATABLE READ snapshot or materialized view refreshed hourly.

    • Symptom: RDS CPU 100%, replica lag 20 minutes during report cron.
    • EXPLAIN showed: Seq Scan on orders — no usable index for date range + status.
    • Fix: Index + query rewrite + read replica dedicated to reporting.

    Performance considerations

    SQL performance for Java teams:

    • Index selectivity: Index low-cardinality columns alone (status) rarely helps — combine with high-cardinality.
    • Covering index: INCLUDE columns — index-only scan avoids heap fetch.
    • Batch inserts: JDBC batch or COPY — 1000 single INSERTs vs one batch = 100x faster.
    • Connection pool vs threads: Pool size ≈ concurrent DB queries — not thread count with virtual threads.
    • Limit result sets: Always pagination — OFFSET expensive at depth; keyset pagination for large tables.

    Security considerations

    SQL security:

    • Parameterized queries: JdbcTemplate and JPA bind parameters — never string concat user input.
    • Least privilege: App DB user no DDL in prod — migrations via CI role.
    • Row-level security: PostgreSQL RLS for multi-tenant — tenant_id policy at DB layer.
    • Audit sensitive reads: Log access to PII columns — compliance requirement.

    Scalability considerations

    Scaling SQL workloads:

    • Read replicas: Route @Transactional(readOnly=true) to replica — watch replication lag.
    • Partitioning: transfers by created_at month — partition pruning on time-range queries.
    • Archival: Cold data to object storage — keep hot tables small for index efficiency.
    • Connection pooling: HikariCP — tune maxLifetime below DB idle timeout.

    Production challenges

    Real SQL production issues:

    • Lock wait timeout: Long TX holds row locks — other transfers block — keep TX short.
    • Deadlock in DB: Two TX lock rows in opposite order — PostgreSQL detects, one rolls back — retry logic needed.
    • Index bloat: Unused indexes slow writes — monitor pg_stat_user_indexes.
    • Migration lock: ALTER TABLE blocks writes — use online migration tools (pt-online-schema-change, CONCURRENTLY).
    • ORM-generated bad SQL: Hibernate cartesian product on eager fetch — fix fetch strategy or explicit join.

    Common mistakes

    • SELECT * in production queries — wastes I/O; name columns explicitly.
    • Function on indexed column: WHERE YEAR(created_at)=2026 — index unusable; use range instead.
    • Missing index on foreign key columns — JOIN and CASCADE slow.
    • Long transaction holding connection during HTTP call to external API.
    • Using OFFSET 100000 for pagination — scans skipped rows; use keyset pagination.

    Debugging guide

    Debug SQL from Java services:

    • Enable SQL logging: logging.level.org.hibernate.SQL=DEBUG (dev only — verbose).
    • pg_stat_statements: Find top queries by total_time in PostgreSQL.
    • EXPLAIN from logs: Copy Hibernate SQL → prepend EXPLAIN ANALYZE in psql.
    • HikariCP metrics: pending threads, connection timeout — pool vs slow query diagnosis.
    bash
    # PostgreSQL — slow queries
    SELECT query, calls, mean_exec_time, total_exec_time
    FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
    # Active locks
    SELECT pid, relation::regclass, mode, granted
    FROM pg_locks WHERE NOT granted;
    # Spring — bind parameters logged
    logging.level.org.hibernate.orm.jdbc.bind=TRACE

    Best practices

    • Every foreign key column has an index — JOIN and ON DELETE performance.
    • Review EXPLAIN for any query running > 100ms in production.
    • Keep transactions short — no external I/O inside @Transactional.
    • Use Flyway/Liquibase for schema — never manual prod DDL.
    • Parameterize all dynamic SQL — PreparedStatement always.
    • Choose isolation deliberately — default READ COMMITTED sufficient for most OLTP.

    Anti-patterns

    • ORM-only mindset — never reading generated SQL in logs.
    • God table with 200 columns — normalize or partition.
    • Sargable violations — wrapping indexed columns in functions.
    • SELECT COUNT(*) on billion-row table for pagination total every request.
    • Using database as message queue — table grows unbounded without archival.

    Staff engineer notes

    • If Hibernate is slow, the first step is reading SQL — not tuning JVM heap.
    • Composite index column order: equality filters first, then range, then ORDER BY columns.
    • FOR UPDATE is a scalpel — row lock only what you need, release fast.
    • Report queries belong on replica or batch job — never compete with OLTP on primary.
    • In code review: native query without EXPLAIN in PR description for new hot path is a question.

    Interview questions

    Interview preparation

    15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.

    Beginner

    5
    1. 1Difference INNER JOIN and LEFT JOIN?
      Beginner

      Model answer

      INNER JOIN returns only rows where join condition matches in both tables.

      LEFT (OUTER) JOIN returns all rows from left table plus matching from right; non-matches have NULL for right columns.

      g.

      all accounts with optional transfers).

      Follow-up probe

      RIGHT JOIN?

    2. 2What is a database index?
      Beginner

      Model answer

      Data structure (usually B-tree) speeding lookups on column(s).

      Trades write overhead and storage for read performance.

      Query planner uses index for WHERE, JOIN, ORDER BY on indexed columns.

      Primary key automatically indexed.

      Follow-up probe

      When index hurts?

    3. 3Explain ACID transactions.
      Beginner

      Model answer

      Atomicity: all or nothing.

      Consistency: constraints preserved.

      Isolation: concurrent TX don't interfere improperly.

      Durability: committed data survives crash.

      Spring @Transactional wraps JDBC transaction with commit/rollback.

      Follow-up probe

      Auto-commit default?

    4. 4Transaction isolation levels?
      Beginner

      Model answer

      READ UNCOMMITTED: dirty reads.

      READ COMMITTED: see committed only (PostgreSQL default).

      REPEATABLE READ: same snapshot within TX.

      SERIALIZABLE: full isolation, may fail with serialization errors.

      Higher isolation = more locking/retries, fewer anomalies.

      Follow-up probe

      Phantom read?

    5. 5What is EXPLAIN ANALYZE?
      Beginner

      Model answer

      • PostgreSQL command showing query execution plan with actual row counts and timing. Reveals Seq Scan vs Index Scan, join methods, cost estimates. Run on production-like data volumes
      • dev with 100 rows misleads.

      Follow-up probe

      Seq Scan always bad?

    Intermediate

    5
    1. 6Design index for WHERE account_id = ? AND created_at > ?
      Intermediate

      Model answer

      • Composite B-tree index (account_id, created_at DESC). account_id equality first (high selectivity), created_at for range and ORDER BY. Partial index if always filtering status='COMPLETED'. Verify with EXPLAIN
      • Index Scan expected.

      Follow-up probe

      Covering index?

    2. 7Why avoid SELECT *?
      Intermediate

      Model answer

      • Fetches unnecessary columns
      • more I/O, memory, network. Breaks covering index optimization. Schema change breaks clients expecting column order. Name required columns explicitly in production SQL and DTO projections.

      Follow-up probe

      SELECT * in dev?

    3. 8Difference EXISTS and IN subquery?
      Intermediate

      Model answer

      • EXISTS stops at first match
      • often faster for correlated subqueries. IN materializes subquery result
      • fine for small sets. Modern optimizers often rewrite similarly
      • benchmark on your data. EXISTS preferred for null-safe correlation.

      Follow-up probe

      JOIN vs subquery?

    4. 9What causes database deadlock?
      Intermediate

      Model answer

      • Two transactions lock resources in opposite order
      • TX1 locks row A waits B, TX2 locks B waits A. DBMS detects, aborts one (victim). Fix: consistent lock ordering, shorter TX, retry on deadlock exception. Spring @Retryable on transient deadlock.

      Follow-up probe

      Deadlock vs lock wait?

    5. 10How does @Transactional(readOnly=true) help?
      Intermediate

      Model answer

      • Hint to ORM: no flush, no dirty checking writes. Some drivers route to read replica. Connection set read-only where supported. Use for query services
      • not just semantic, can enable optimizations.

      Follow-up probe

      readOnly on write method?

    Advanced

    5
    1. 11Optimize pagination for 10M row table.
      Advanced

      Model answer

      • Avoid OFFSET for deep pages
      • scans skipped rows. Keyset (seek) pagination: WHERE id > :lastId ORDER BY id LIMIT 20. Requires stable sort key. For page numbers, approximate count or cache totals.

      Follow-up probe

      Cursor in Spring Data?

    2. 12When use native SQL over JPQL?
      Advanced

      Model answer

      • Complex aggregations, window functions, database-specific features (FOR UPDATE SKIP LOCKED, JSON operators), bulk updates, reporting. JPQL for entity-centric CRUD. Native when SQL clarity and planner control matter
      • still parameterize.

      Follow-up probe

      SqlResultSetMapping?

    3. 13Design transfer schema preventing double-spend.
      Advanced

      Model answer

      • accounts.balance_cents CHECK >= 0. transfers with status enum. Transaction: SELECT FOR UPDATE on account row, verify balance, UPDATE balance, INSERT transfer
      • single TX. Idempotency key unique constraint. Audit table append-only.

      Follow-up probe

      Optimistic vs pessimistic?

    4. 14Read replica lag causing stale reads — options?
      Advanced

      Model answer

      • Route critical reads to primary. Monotonic reads via sticky session (limited). Display 'processing' UI until replication catches up. Use CDC/event for read models. Measure lag metric
      • alert if > SLA.

      Follow-up probe

      Spring AbstractRoutingDataSource?

    5. 15Incident: connection pool exhausted — SQL or pool?
      Advanced

      Model answer

      • Check HikariCP pending threads vs active. If active=max and queries slow
      • SQL tuning. If active low but pending
      • leak (connection not returned) or thread explosion. Thread dump + pg_stat_activity for long running queries. Fix leak or tune pool/SQL.

      Follow-up probe

      Pool size formula?

    Hands-on exercise

    Lab: SQL for Java developers

    • Write INNER JOIN query: transfers + account balances for last 7 days.
    • Add composite index — compare EXPLAIN before/after.
    • Simulate transaction: debit + credit in single JDBC transaction with rollback test.
    • Rewrite OFFSET pagination to keyset pagination on id.
    • Find N+1 in Hibernate log — replace with JOIN FETCH or @EntityGraph.

    JavaSQL for Java Developers: Joins, Indexes, Transactions, Query Optimization

    Starter Templates
    OutputRemote JVM (Piston · Java 15)
    Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.

    Architecture trade-offs

    • Normalization vs denormalization: Normal form for OLTP; denormalize read models for reports.
    • Pessimistic vs optimistic locking: FOR UPDATE vs @Version — contention vs retry.
    • JPQL vs native SQL: Portability vs planner control and DB features.
    • Index count: More indexes faster reads, slower writes — measure write path impact.

    Summary

    SQL literacy separates Java developers who ship reliable backends from those who blame Hibernate. Master joins, indexes, transactions, and EXPLAIN-driven optimization — then JPA becomes a tool you control, not a black box that controls you. Next: JPA and Hibernate internals — persistence context, dirty checking, N+1, and caching.

    Key takeaways

    • JOINs express relational data — INNER for matches, LEFT to preserve driving table rows.
    • Index WHERE and JOIN columns — composite order: equality, range, ORDER BY.
    • Transactions are ACID — keep them short, no external I/O inside @Transactional.
    • EXPLAIN ANALYZE is mandatory for slow query investigation — read SQL Hibernate generates.
    • Production: parameterized queries, Flyway migrations, read replicas for reports, keyset pagination.
    Ready to mark this lesson complete?Track your journey across the entire course.