SQL Basics

The language every Java database layer speaks underneath — understanding SQL is what separates a developer who can use JPA from one who can debug and optimize it

← Back to Index

What is SQL — and Why Does a Java Developer Need It?

SQL (Structured Query Language) is the declarative language used to interact with every relational database — PostgreSQL, MySQL, Oracle, SQL Server, H2. You describe what data you want; the database engine decides how to retrieve it. It's not a programming language — there are no loops, no variables in standard SQL, no control flow. That's a deliberate design: the optimizer can choose the fastest execution plan without being constrained by the order the developer wrote the operations in.

Java ORMs like JPA and Hibernate generate SQL automatically. This is not a reason to skip learning SQL — it's a reason to learn it deliberately, because when a query is slow or returns the wrong data, the first debugging step is always "what SQL did the ORM actually send to the database?" You can't read that output, understand the execution plan, or fix the problem without understanding SQL.

/*
 * BEFORE JPA existed — you wrote SQL directly via JDBC.
 * This still happens for complex queries, migrations, and analytics.
 */
SELECT
    o.id,
    c.email,
    SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN customers c  ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PENDING'
  AND o.created_at >= NOW() - INTERVAL '24 HOURS'
GROUP BY o.id, c.email
HAVING SUM(oi.quantity * oi.unit_price) > 100
ORDER BY total DESC;

/*
 * JPA with Hibernate generates SQL automatically from entity queries.
 * Enable logging to see what it actually sends — this is the first tool
 * for debugging unexpected results or slow queries.
 */
# application.properties — see the SQL JPA generates
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace  # shows parameter values too
SQL CategoryCommandsWhat it does
DDL (Data Definition)CREATE, ALTER, DROP, TRUNCATEDefine and modify schema structure
DML (Data Manipulation)SELECT, INSERT, UPDATE, DELETERead and write data — 90% of daily work
TCL (Transaction Control)COMMIT, ROLLBACK, SAVEPOINTManage atomic units of work
DCL (Data Control)GRANT, REVOKEUser permissions — usually the DBA's domain

Defining Schema: DDL

The schema lives in version-controlled migration files (Flyway, Liquibase) in production — not written by hand in a SQL console. Understanding DDL is essential for writing those migrations and for understanding what JPA's ddl-auto=create generates (which you should never use in production).

-- An e-commerce schema: three tables, two foreign keys, essential constraints

CREATE TABLE customers (
    id          BIGINT       PRIMARY KEY GENERATED ALWAYS AS IDENTITY,  -- standard SQL:2003, preferred over AUTO_INCREMENT
    email       VARCHAR(255) NOT NULL UNIQUE,
    full_name   VARCHAR(200) NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()   -- TIMESTAMPTZ: timestamp WITH time zone — always prefer this over TIMESTAMP
);

CREATE TABLE products (
    id          BIGINT          PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    sku         VARCHAR(50)     NOT NULL UNIQUE,
    name        VARCHAR(255)    NOT NULL,
    price       NUMERIC(12, 2)  NOT NULL CHECK (price >= 0),   -- NUMERIC not FLOAT for money
    stock       INTEGER         NOT NULL DEFAULT 0 CHECK (stock >= 0)
);

CREATE TABLE orders (
    id          BIGINT       PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    customer_id BIGINT       NOT NULL REFERENCES customers(id),
    status      VARCHAR(20)  NOT NULL DEFAULT 'PENDING'
                    CHECK (status IN ('PENDING', 'PAID', 'SHIPPED', 'CANCELLED')),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE order_items (
    order_id    BIGINT         NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id  BIGINT         NOT NULL REFERENCES products(id),
    quantity    INTEGER        NOT NULL CHECK (quantity > 0),
    unit_price  NUMERIC(12, 2) NOT NULL CHECK (unit_price >= 0),  -- snapshot at purchase time
    PRIMARY KEY (order_id, product_id)
);

-- Indexes for the columns you'll filter and join on
CREATE INDEX idx_orders_customer   ON orders(customer_id);
CREATE INDEX idx_orders_status      ON orders(status);
CREATE INDEX idx_order_items_product ON order_items(product_id);
Three DDL decisions that matter in production

Use NUMERIC (or DECIMAL), never FLOAT or DOUBLE, for money. Floating-point types cannot represent values like 0.10 exactly — they store approximations. Storing prices or totals in a FLOAT column will produce rounding errors that accumulate over time into real financial discrepancies. NUMERIC(12,2) stores exactly two decimal places, always.

Use TIMESTAMPTZ (timestamp with time zone), not TIMESTAMP. A bare TIMESTAMP stores what you give it and assumes you know the time zone. When your application or database server crosses a DST boundary or changes time zones, bare timestamps become ambiguous. TIMESTAMPTZ converts to UTC on storage and back to the session time zone on retrieval — unambiguous at all times.

Snapshot the price in order_items, not just the product_id. If you only store the product reference, and the product's price changes next week, every historical order recalculates with the new price. unit_price captures what the customer actually paid.

Modifying Tables Safely

-- Add a column (safe — existing rows get NULL or the DEFAULT)
ALTER TABLE customers ADD COLUMN phone VARCHAR(30);

-- Adding a NOT NULL column to a large table: ALWAYS add nullable first,
-- backfill data, THEN add the constraint. Adding NOT NULL without a default
-- on a table with existing rows will fail or lock the table entirely.
ALTER TABLE customers ADD COLUMN phone VARCHAR(30);                         -- step 1: nullable
UPDATE customers SET phone = 'UNKNOWN' WHERE phone IS NULL;               -- step 2: backfill
ALTER TABLE customers ALTER COLUMN phone SET NOT NULL;                      -- step 3: constrain

-- Drop a column (PostgreSQL 15+ can do this online; older versions lock)
ALTER TABLE customers DROP COLUMN phone;

-- Add index without locking reads (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_customers_email ON customers(email);
-- Standard CREATE INDEX locks writes for the duration — use CONCURRENTLY in production

Reading and Writing Data: DML

INSERT

-- Insert a single row, explicit columns (always list columns — never omit them)
INSERT INTO customers (email, full_name)
VALUES ('ada@example.com', 'Ada Lovelace')
RETURNING id;   -- PostgreSQL: get the generated ID back in the same statement

-- Batch insert (one statement, many rows — dramatically faster than N individual INSERTs)
INSERT INTO products (sku, name, price) VALUES
    ('SKU-001', 'Laptop Stand', 49.99),
    ('SKU-002', 'USB-C Hub',    29.99),
    ('SKU-003', 'Keyboard',     89.99);

-- Upsert: insert or update if the unique key already exists
-- PostgreSQL:
INSERT INTO products (sku, name, price)
VALUES ('SKU-001', 'Laptop Stand Pro', 59.99)
ON CONFLICT (sku) DO UPDATE SET
    name  = EXCLUDED.name,
    price = EXCLUDED.price;

-- MySQL / MariaDB:
INSERT INTO products (sku, name, price)
VALUES ('SKU-001', 'Laptop Stand Pro', 59.99)
ON DUPLICATE KEY UPDATE
    name  = VALUES(name),
    price = VALUES(price);

SELECT — The Most Important Statement

-- Always select specific columns — never SELECT * in application code
SELECT id, email, full_name FROM customers WHERE id = 42;

-- The six clauses, in the order SQL executes them (not the order you write them):
-- 1. FROM + JOINs  → determines the working set
-- 2. WHERE         → filters rows from that set
-- 3. GROUP BY      → collapses rows into groups
-- 4. HAVING        → filters groups (like WHERE, but after grouping)
-- 5. SELECT        → picks and computes the output columns
-- 6. ORDER BY / LIMIT → sorts and truncates the final result

-- Filtering
SELECT id, email FROM customers
WHERE created_at >= '2026-01-01'
  AND created_at <  '2027-01-01'   -- range on indexed column: uses the index
  AND email LIKE 'ada%';            -- trailing wildcard: uses the index. Leading %: does not.

-- NULL handling — NULL is not equal to anything, including itself
WHERE phone IS NULL       -- correct
WHERE phone = NULL        -- always false, even when phone IS NULL

-- Pagination: OFFSET has a performance problem at scale (the database
-- scans and discards all rows up to the offset). Keyset pagination
-- avoids this by filtering on the last seen ID instead.
-- OFFSET pagination (simple, slow at high page numbers):
SELECT id, email FROM customers ORDER BY id LIMIT 20 OFFSET 100;

-- Keyset pagination (efficient regardless of depth):
SELECT id, email FROM customers
WHERE id > 120   -- last ID seen on the previous page
ORDER BY id LIMIT 20;

UPDATE and DELETE

-- Always use a WHERE clause. Always.
UPDATE products
SET price = price * 1.10, updated_at = NOW()
WHERE id = 42;

-- Before running a destructive UPDATE or DELETE in production:
-- 1. Run a SELECT with the same WHERE clause and verify the count
-- 2. Wrap in a transaction
-- 3. COMMIT only after verifying the result
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'CANCELLED' AND created_at < '2025-01-01';
-- verify the number before proceeding
DELETE FROM orders WHERE status = 'CANCELLED' AND created_at < '2025-01-01';
ROLLBACK;  -- or COMMIT once you're sure

-- Soft delete: preserve history, filter instead of delete
ALTER TABLE orders ADD COLUMN deleted_at TIMESTAMPTZ;

UPDATE orders SET deleted_at = NOW() WHERE id = 9001;

SELECT * FROM orders WHERE deleted_at IS NULL;   -- your "active" view

JOINs: Combining Tables

JOINs are the most important concept in relational SQL after SELECT. Every ORM relationship ( @ManyToOne, @OneToMany) maps to a JOIN at the database level.

-- Working dataset for the examples below

-- customers
┌────┬──────────────────────┐
│ id │ email                │
├────┼──────────────────────┤
│  1 │ ada@example.com      │
│  2 │ alan@example.com     │
│  3 │ grace@example.com    │  -- has no orders
└────┴──────────────────────┘

-- orders
┌────┬─────────────┬──────────┐
│ id │ customer_id │ status   │
├────┼─────────────┼──────────┤
│ 101│      1      │ PAID     │
│ 102│      1      │ PENDING  │
│ 103│      2      │ SHIPPED  │
│ 104│      9      │ PAID     │  -- orphaned: customer 9 doesn't exist
└────┴─────────────┴──────────┘
-- INNER JOIN: only rows that match on both sides
SELECT c.email, o.id AS order_id, o.status
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;
-- Result: Ada (2 rows), Alan (1 row). Grace omitted (no orders). Order 104 omitted (no customer).

-- LEFT JOIN: all rows from the left table, NULLs where no match on right
SELECT c.email, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
-- Result: Ada (2 rows), Alan (1 row), Grace (1 row with NULL order_id).
-- Use LEFT JOIN when you need all left-side records regardless of matches.

-- Finding customers with no orders at all (anti-join pattern)
SELECT c.email
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;   -- Grace

-- Multiple JOINs: orders with their items and product names
SELECT
    o.id         AS order_id,
    c.email,
    p.name       AS product,
    oi.quantity,
    oi.unit_price
FROM orders o
JOIN customers   c  ON c.id  = o.customer_id
JOIN order_items oi ON oi.order_id  = o.id
JOIN products    p  ON p.id  = oi.product_id
WHERE o.status = 'PAID'
ORDER BY o.id, p.name;
RIGHT JOIN is almost always avoidable

Every RIGHT JOIN can be rewritten as a LEFT JOIN by swapping the table order. Most developers find LEFT JOIN more readable because the "primary" table stays on the left. Prefer LEFT JOIN and rearrange the FROM/JOIN order rather than using RIGHT JOIN.

Aggregation: GROUP BY, HAVING, and Window Functions

GROUP BY and Aggregate Functions

-- Order totals per customer
SELECT
    c.email,
    COUNT(o.id)                                      AS order_count,
    SUM(oi.quantity * oi.unit_price)                  AS lifetime_value,
    MAX(o.created_at)                                 AS last_order_date
FROM customers c
JOIN orders o      ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status != 'CANCELLED'        -- WHERE filters rows BEFORE aggregation
GROUP BY c.id, c.email
HAVING SUM(oi.quantity * oi.unit_price) > 1000   -- HAVING filters AFTER aggregation
ORDER BY lifetime_value DESC;

-- Most popular products (by total units sold)
SELECT
    p.sku,
    p.name,
    SUM(oi.quantity) AS units_sold
FROM products p
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o       ON o.id = oi.order_id
WHERE o.status = 'PAID'
GROUP BY p.id, p.sku, p.name
ORDER BY units_sold DESC
LIMIT 10;

CTEs — Readable Complex Queries

-- Same query as above, written with a CTE for clarity
WITH paid_item_totals AS (
    SELECT
        oi.product_id,
        SUM(oi.quantity) AS units_sold
    FROM order_items oi
    JOIN orders o ON o.id = oi.order_id
    WHERE o.status = 'PAID'
    GROUP BY oi.product_id
)
SELECT
    p.sku,
    p.name,
    pit.units_sold
FROM products p
JOIN paid_item_totals pit ON pit.product_id = p.id
ORDER BY pit.units_sold DESC
LIMIT 10;

-- CTEs don't change performance (they're rewritten by the optimizer in most databases),
-- but they make complex queries dramatically easier to read and debug.

Window Functions — Ranking Without Losing Rows

Window functions compute across related rows without collapsing them the way GROUP BY does. The canonical e-commerce use case: rank products by revenue within each category.

-- Top 3 products by revenue per category (window function)
WITH product_revenue AS (
    SELECT
        p.id,
        p.name,
        p.category,
        SUM(oi.quantity * oi.unit_price) AS revenue
    FROM products p
    JOIN order_items oi ON oi.product_id = p.id
    JOIN orders o       ON o.id = oi.order_id AND o.status = 'PAID'
    GROUP BY p.id, p.name, p.category
),
ranked AS (
    SELECT
        *,
        RANK() OVER (
            PARTITION BY category   -- rank within each category, not globally
            ORDER BY revenue DESC
        ) AS rank_in_category
    FROM product_revenue
)
SELECT category, name, revenue, rank_in_category
FROM ranked
WHERE rank_in_category <= 3
ORDER BY category, rank_in_category;

-- RANK vs DENSE_RANK vs ROW_NUMBER for ties:
-- RANK():       1, 2, 2, 4   (gap after ties)
-- DENSE_RANK(): 1, 2, 2, 3   (no gap)
-- ROW_NUMBER(): 1, 2, 3, 4   (unique, arbitrary for ties)

Indexes and Query Performance

An index is a data structure (usually a B-tree) maintained alongside a table that allows the database to find rows matching a condition without scanning every row. The trade-off: faster reads, slower writes (the index must be updated on INSERT/UPDATE/DELETE), and more storage.

When to add an index

-- Index candidates: columns in WHERE, JOIN ON, and ORDER BY clauses
-- that appear in queries run frequently or on large tables.

-- Single column
CREATE INDEX idx_orders_status ON orders(status);

-- Composite: useful when you always filter on both columns together.
-- Column order matters: put the most selective column first.
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
-- This serves: WHERE status = 'PAID' AND created_at > '...'
-- But NOT: WHERE created_at > '...' alone (leading column must appear)

-- Partial index: only index the rows you actually query
CREATE INDEX idx_orders_pending ON orders(created_at)
    WHERE status = 'PENDING';
-- Smaller, faster than a full index if PENDING is a tiny fraction of all orders

Reading EXPLAIN output

-- Add EXPLAIN ANALYZE before any slow query to see the execution plan
EXPLAIN ANALYZE
SELECT id, status FROM orders WHERE customer_id = 42 AND status = 'PENDING';

-- Output tells you:
-- Seq Scan     → full table scan. Fine for small tables, bad for large ones.
-- Index Scan   → uses an index. Usually what you want.
-- Index Only Scan → best: all needed columns are in the index itself.
-- rows=N       → how many rows the planner estimated (vs actual=N).
-- cost=N.N     → relative cost estimate. Higher is slower.

-- The three most common reasons a query ignores an existing index:
-- 1. Function applied to the column: YEAR(created_at) = 2026 → no index
--    Fix: created_at >= '2026-01-01' AND created_at < '2027-01-01'
-- 2. Leading wildcard: name LIKE '%laptop' → no index
--    Fix: use full-text search for contains queries
-- 3. Type mismatch: WHERE id = '42' when id is BIGINT → implicit cast, no index
--    Fix: match the column's type exactly
SELECT * in application code is almost always wrong

Besides transferring more data than needed, SELECT * prevents the database from using an Index Only Scan — a scan that never touches the main table at all because all needed columns are in the index. When you select only id and email from a table that has an index on (email, id), the database can answer the entire query from the index. SELECT * forces it to go to the table for every row.

SQL Injection — Why Parameterized Queries Are Non-Negotiable

SQL injection is the most exploited database vulnerability. It happens when user-supplied input is concatenated directly into a SQL string, allowing the input to change the structure of the query.

// VULNERABLE — string concatenation with user input
String email = request.getParameter("email");
String sql = "SELECT * FROM customers WHERE email = '" + email + "'";

// If email = "' OR '1'='1", the query becomes:
// SELECT * FROM customers WHERE email = '' OR '1'='1'
// This returns every row in the table.

// If email = "'; DROP TABLE customers; --", the query becomes two statements.
// The database executes both. Your table is gone.
// CORRECT — parameterized query (PreparedStatement)
// The ? is a placeholder. The value is sent separately from the SQL structure.
// No matter what the user types, it can never change the structure of the query.
String sql = "SELECT id, email FROM customers WHERE email = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, email);   // the value is bound, not concatenated
    ResultSet rs = ps.executeQuery();
}

// JPA / Spring Data protects you automatically when you use parameters:
// SAFE — parameter binding
@Query("SELECT c FROM Customer c WHERE c.email = :email")
Optional<Customer> findByEmail(@Param("email") String email);

// DANGEROUS even with JPA — never concatenate into JPQL
String jpql = "FROM Customer WHERE email = '" + email + "'";  // ✗
em.createQuery(jpql);
Parameterization is not optional — it's a baseline requirement

String concatenation into any SQL or JPQL query is wrong, regardless of whether the value comes from a user, a config file, or another database column. Parameterized queries also allow the database to cache and reuse the execution plan for the same query with different values — a performance benefit on top of the security one. See JDBC for the complete PreparedStatement pattern in production code.

Best Practices and Common Pitfalls

✅ Do

  • Always use NUMERIC/DECIMAL for monetary values — FLOAT and DOUBLE introduce rounding errors that compound over time
  • Use TIMESTAMPTZ (or the equivalent in your database) for all timestamps — bare TIMESTAMP without time zone becomes ambiguous across DST transitions and server migrations
  • Snapshot prices and other point-in-time values in transactional tables — store unit_price in order_items, not just the product reference
  • Add indexes on columns you filter, sort, and join on — but measure with EXPLAIN ANALYZE before and after; not every index helps
  • Wrap destructive UPDATE and DELETE statements in a transaction, verify the count with a SELECT first, and only COMMIT when the count is correct
  • Use keyset pagination (WHERE id > last_seen_id) instead of OFFSET for large datasets — OFFSET 10000 scans and discards 10,000 rows on every page request

❌ Don't

  • Don't concatenate user input into SQL strings — use parameterized queries (PreparedStatement) unconditionally
  • Don't use SELECT * in application code — it transfers more data than needed and prevents index-only scans
  • Don't apply functions to indexed columns in WHERE clauses (YEAR(created_at) = 2026) — the index cannot be used; convert to a range instead
  • Don't add a NOT NULL column to a large table in one step — add it nullable first, backfill, then add the constraint
  • Don't set spring.jpa.hibernate.ddl-auto=create or create-drop in production — use Flyway or Liquibase for controlled migrations

Interview Questions

🎓 Junior level

Q: What is the difference between WHERE and HAVING?
WHERE filters individual rows before grouping. HAVING filters groups after GROUP BY has been applied. You can't use aggregate functions like COUNT or SUM in a WHERE clause — they don't exist yet at that stage of execution. That's what HAVING is for.

Q: What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns only rows where there is a matching row in both tables. LEFT JOIN returns all rows from the left table, with NULL in the right-side columns where there is no match. Use LEFT JOIN when you want to keep all records from the left table even if some have no related records on the right.

Q: What is SQL injection, and how do you prevent it?
SQL injection is when user-supplied input is concatenated into a SQL string, allowing the input to change the structure of the query and execute unintended commands. Prevention: always use parameterized queries (PreparedStatement in JDBC, named parameters in JPQL). The user's value is sent separately from the SQL structure and can never be interpreted as SQL, regardless of what it contains.

🔥 Senior level

Q: A query on orders filtering by status and created_at is slow. There's already an index on status. You add an index on created_at. The query is still slow. What do you check next?
Run EXPLAIN ANALYZE on the query and look at which index (if any) is being used. Two separate single-column indexes on status and created_at are not automatically combined — the optimizer must choose one and filter the other in memory, or perform a bitmap index scan that's slower than a composite. A composite index on (status, created_at) serves a query that filters on both in a single scan. The column order matters: the most selective column (or the one used for equality, not range) should go first. If the optimizer still isn't using the index, check for a function applied to the column (DATE(created_at)), an implicit type mismatch, or table statistics that are out of date and causing the planner to underestimate selectivity.

Q: Your application uses OFFSET-based pagination. On page 500 (OFFSET 9980 with page size 20), queries take several seconds. Page 1 is instant. Why, and what's the fix?
OFFSET N instructs the database to scan and discard the first N rows before returning the next page's worth. On page 500 with page size 20, the database reads 9,980 rows it throws away before returning the 20 you want — and this cost grows linearly with page number. An index on id doesn't help with this; the work is in discarding, not in locating. The fix is keyset pagination: instead of OFFSET, filter on the last ID seen — WHERE id > 9980 ORDER BY id LIMIT 20. The database goes directly to that point in the index and reads exactly 20 rows, regardless of how deep into the dataset you are. The trade-off is that keyset pagination can't jump to an arbitrary page number — it is cursor-based, always relative to the previous page's last item.

Q: You need to add a NOT NULL column to a customers table with 50 million rows in a production PostgreSQL database. What's the safe migration path?
Adding a NOT NULL column without a default in a single ALTER TABLE requires a full table rewrite — it locks the table for the entire duration, which on 50 million rows could be minutes to an hour. The safe approach is three steps: first, ALTER TABLE customers ADD COLUMN phone VARCHAR(30) — in PostgreSQL 11+, adding a nullable column with no default is instant (metadata change only); second, backfill the new column in batches with small committed transactions rather than one large UPDATE that holds a lock and generates a massive write-ahead log; third, once all rows are populated, ALTER TABLE customers ALTER COLUMN phone SET NOT NULL — which in PostgreSQL 12+ with a NOT NULL constraint check already added (ADD CONSTRAINT ... CHECK (phone IS NOT NULL) NOT VALID followed by VALIDATE CONSTRAINT) can be done without a full table lock.