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 Category | Commands | What it does |
|---|---|---|
| DDL (Data Definition) | CREATE, ALTER, DROP, TRUNCATE | Define and modify schema structure |
| DML (Data Manipulation) | SELECT, INSERT, UPDATE, DELETE | Read and write data — 90% of daily work |
| TCL (Transaction Control) | COMMIT, ROLLBACK, SAVEPOINT | Manage atomic units of work |
| DCL (Data Control) | GRANT, REVOKE | User 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);
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;
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
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);
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/DECIMALfor monetary values —FLOATandDOUBLEintroduce rounding errors that compound over time - Use
TIMESTAMPTZ(or the equivalent in your database) for all timestamps — bareTIMESTAMPwithout time zone becomes ambiguous across DST transitions and server migrations - Snapshot prices and other point-in-time values in transactional tables — store
unit_priceinorder_items, not just the product reference - Add indexes on columns you filter, sort, and join on — but measure with
EXPLAIN ANALYZEbefore and after; not every index helps - Wrap destructive
UPDATEandDELETEstatements in a transaction, verify the count with aSELECTfirst, and onlyCOMMITwhen the count is correct - Use keyset pagination (
WHERE id > last_seen_id) instead ofOFFSETfor large datasets —OFFSET 10000scans 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
WHEREclauses (YEAR(created_at) = 2026) — the index cannot be used; convert to a range instead - Don't add a
NOT NULLcolumn to a large table in one step — add it nullable first, backfill, then add the constraint - Don't set
spring.jpa.hibernate.ddl-auto=createorcreate-dropin production — use Flyway or Liquibase for controlled migrations
Interview Questions
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.
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.