Lesson 19 of 20

SQL Best Practices

Writing SQL That Other People Can Read

Most SQL you write will be read many more times than it is written, usually by somebody who did not write it and occasionally by you six months later. There is no single official style, but the conventions below are common enough that following them makes your schema look familiar to a reviewer, and picking one convention and sticking to it matters more than which one you pick.

For names: snake_case throughout, because MySQL on Linux treats table names as case-sensitive while MySQL on Windows does not, and OrderItems is a portability problem waiting for a deployment. Plural table names (students, orders) since a table holds many rows; singular column names. Foreign keys named after what they point at, so student_id references students(id) — that consistency is what lets anybody read a join without checking the schema. Spell words out: created_at, not ca. And avoid reserved words entirely. A column called order, group, rank or status forces every query that mentions it to be quoted, and the quoting character is different in every database — backticks in MySQL, double quotes in PostgreSQL and the standard, square brackets in SQL Server.

For the statements themselves: uppercase keywords and lowercase identifiers, so the structure is visible at a glance. One clause per line, and one column per line once a select list stops fitting comfortably. Table aliases that mean something — s for students is fine, t1 and t2 are not. And comments that explain why a strange condition is there, since the what is already in the SQL.

One habit worth more than all the formatting: name the columns you want instead of writing SELECT *. Explicit columns move less data over the network, keep working when somebody adds a column to the table, let a covering index do its job, and tell the reader what the query is actually for. The same applies to INSERT — always list the columns, because an INSERT INTO students VALUES (...) depends on column order and breaks silently the day a column is added in the middle. SELECT * is for exploring at the command line; it should rarely survive into code.

Example
-- Names that read the same way everywhere
CREATE TABLE product_categories (
    id                 INT AUTO_INCREMENT PRIMARY KEY,
    name               VARCHAR(100) NOT NULL,
    parent_category_id INT,
    created_at         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at         TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                                 ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (parent_category_id) REFERENCES product_categories(id)
);

-- Readable: keywords up, identifiers down, one clause per line
SELECT
    s.name,
    s.branch,
    COUNT(o.id)                AS order_count,
    COALESCE(SUM(o.amount), 0) AS total_spent
FROM students s
LEFT JOIN orders o ON o.student_id = s.id
WHERE s.joined_on >= '2026-01-01'
GROUP BY s.id, s.name, s.branch
HAVING COUNT(o.id) > 0
ORDER BY total_spent DESC
LIMIT 20;

-- Name the columns. Both of these survive a schema change.
SELECT id, name, email FROM students WHERE id = 42;
INSERT INTO students (name, email, branch) VALUES ('Ravi K', 'ravi@example.com', 'ECE');

-- Neither of these does
-- SELECT * FROM students WHERE id = 42;
-- INSERT INTO students VALUES (NULL, 'Ravi K', 'ravi@example.com', 'ECE');

-- A reserved word needs quoting, and the quoting differs by database
-- MySQL:       SELECT `order` FROM items;
-- PostgreSQL:  SELECT "order" FROM items;
-- SQL Server:  SELECT [order] FROM items;
-- Simplest fix: do not name a column 'order'.
Notes
  • MySQL will accept double quotes around a string literal, so WHERE name = "Ananya" works there. In PostgreSQL and in standard SQL, double quotes mean an identifier, so the same line becomes a request for a column named Ananya and fails with a confusing error. Use single quotes for strings, always, in every database.

SQL Injection, and Why Escaping Is Not the Fix

SQL injection happens when user input is pasted into a query string, so that the input can stop being data and start being SQL. If your login code builds "SELECT * FROM students WHERE email = '" + input + "'" and somebody types ' OR '1'='1, the database receives WHERE email = '' OR '1'='1' — a condition true for every row. Nobody broke in; the application politely asked the database to let them in. The same trick with '; DROP TABLE students; -- ends worse. It has appeared in every edition of the OWASP Top Ten list of web application security risks, and it is still one of the most common ways real applications are compromised.

The fix is parameterised queries, also called prepared statements. You send the SQL with placeholders and the values separately, and the database plans the statement first and then binds the values into it. Because the query structure is already decided before the value arrives, a value can never become part of the syntax. ' OR '1'='1 arrives as a harmless email address that simply matches nobody. This is not sanitising the input — it is keeping code and data in separate channels, which is why it works even against attacks nobody has thought of yet.

Do not try to escape your way out of this instead. Manual escaping fails in ways that are hard to see: it does nothing at all in a numeric context, because WHERE id = $input has no quotes for the escaping to protect, so 1 OR 1=1 walks straight in. It has historically been defeated by particular multi-byte character set configurations. And it only takes one query out of two hundred where somebody was in a hurry. Parameterisation has no such failure mode, so use it for every query that touches user input — including the ones where the input 'is only a number'.

Two things placeholders cannot do, and both catch people. A placeholder can only stand for a value, never for a table name, a column name, or the words ASC and DESC. So a sortable table whose column comes from a query string cannot be parameterised; you must check the incoming value against a fixed list you wrote yourself and use only entries from that list. And a placeholder inside LIKE is safe from injection but not from wildcards — a user typing % gets a wildcard, and if that matters you escape % and _ in the value and add an ESCAPE clause.

Example
-- The vulnerable pattern, in any language:
--   sql = 'SELECT * FROM students WHERE email = ' + quote(input)
--
--   input = ' OR '1'='1        -> WHERE email = '' OR '1'='1'   (all rows)
--   input = '; DROP TABLE students; --   -> two statements

-- The safe pattern. The SQL is fixed; only values travel separately.

-- Python (mysql-connector, psycopg2):
--   cur.execute('SELECT id, name FROM students WHERE email = %s', (email,))

-- Node.js (mysql2):
--   conn.execute('SELECT id, name FROM students WHERE email = ?', [email])

-- PHP (PDO):
--   $stmt = $pdo->prepare('SELECT id, name FROM students WHERE email = :email');
--   $stmt->execute(['email' => $email]);

-- Numbers need parameters too. Escaping protects nothing here,
-- because there are no quotes around the value:
--   BAD:  'SELECT * FROM orders WHERE id = ' + id
--   GOOD: conn.execute('SELECT * FROM orders WHERE id = ?', [id])

-- A column name cannot be a parameter. Use an allowlist you control.
--   allowed = { 'name': 'name', 'joined': 'joined_on', 'spent': 'total' }
--   col = allowed[req.query.sort] or 'name'
--   dir = 'DESC' if req.query.dir == 'desc' else 'ASC'
--   sql = 'SELECT id, name FROM students ORDER BY ' + col + ' ' + dir
-- Safe because col and dir can only ever be strings you wrote.

-- LIKE: parameterised and still wildcard-aware
--   conn.execute(
--     "SELECT id, name FROM students WHERE name LIKE ? ESCAPE '!'",
--     ['%' + escapeWildcards(term) + '%'])
-- where escapeWildcards turns  %  _  !  into  !%  !_  !!
Notes
  • Query builders and ORMs parameterise for you, which is most of why they are worth using — but every one of them has an escape hatch (raw, literal, whereRaw) that hands you a plain string, and string interpolation inside that hatch is exactly as dangerous as writing the query by hand. When you review code, those are the lines to look at.

Transactions: All or Nothing

Consider transferring five thousand rupees from one account to another. It is two statements: subtract from one row, add to another. Now imagine the server loses power between them. The money has left the first account and arrived nowhere, and no amount of careful application code fixes that after the fact, because the process that was going to run the second statement no longer exists. A transaction is the database feature that makes this impossible: you mark the two statements as one unit, and either both take effect or neither does.

This is the A in ACID, the four guarantees a transactional database offers. Atomicity is all-or-nothing — a transaction never half-happens. Consistency means a transaction takes the database from one valid state to another, with every constraint you declared still satisfied at the end. Isolation means concurrent transactions do not see each other's unfinished work. Durability means that once COMMIT returns, the data survives a crash, because it has been written somewhere that survives a crash.

By default you are already using transactions without noticing. MySQL and PostgreSQL both run with autocommit on, which wraps every individual statement in its own transaction. Grouping statements means starting one explicitly: START TRANSACTION in MySQL and the SQL standard, BEGIN in PostgreSQL (and also accepted by MySQL), BEGIN TRANSACTION in SQL Server. Then COMMIT to make it real, or ROLLBACK to discard everything since the start.

Two things that surprise people. In MySQL only the InnoDB storage engine is transactional; on a MyISAM table, START TRANSACTION and ROLLBACK run without complaint and protect nothing at all. And in MySQL, a DDL statement such as CREATE TABLE or ALTER TABLE causes an implicit commit, so a schema change inside a transaction quietly commits everything before it and cannot be rolled back. PostgreSQL supports transactional DDL properly, which is why migration tooling written against PostgreSQL behaves differently when pointed at MySQL.

Example
-- The two statements that must never happen separately
START TRANSACTION;

UPDATE accounts SET balance = balance - 5000 WHERE id = 1;
UPDATE accounts SET balance = balance + 5000 WHERE id = 2;

COMMIT;      -- both take effect, permanently
-- ROLLBACK; -- neither took effect, as if nothing happened

-- Do not check the balance in a separate SELECT and then decide.
-- Between the two statements another transfer can slip through.
-- Put the condition in the UPDATE itself:
START TRANSACTION;

UPDATE accounts
SET balance = balance - 5000
WHERE id = 1 AND balance >= 5000;
-- If that affected 0 rows, the balance was insufficient.
-- The application checks the affected-row count and rolls back.

UPDATE accounts SET balance = balance + 5000 WHERE id = 2;

COMMIT;

-- SAVEPOINT: roll back part of a transaction without losing all of it
START TRANSACTION;
INSERT INTO orders (student_id, ordered_at) VALUES (42, NOW());
SAVEPOINT order_created;
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (LAST_INSERT_ID(), 7, 2, 499.00);
-- something is wrong with the item, but keep the order
ROLLBACK TO SAVEPOINT order_created;
COMMIT;

-- Starting a transaction, by dialect
-- MySQL:       START TRANSACTION;   (BEGIN also works)
-- PostgreSQL:  BEGIN;
-- SQL Server:  BEGIN TRANSACTION;

-- MySQL: this table cannot be part of a transaction at all
-- CREATE TABLE legacy_log (...) ENGINE=MyISAM;
Notes
  • A transaction holds locks for as long as it stays open, and other transactions queue behind those locks. So a transaction should cover the database work and nothing else — never wrap a payment gateway call, an email send or a wait for user input inside one. A transaction left open across a slow network call blocks every other request that touches the same rows, and this is a very common cause of an application that is fine in testing and falls over under load.

Concurrency: Isolation Levels and Deadlocks

Isolation is the least understood letter in ACID, and it is a dial rather than a switch. Perfect isolation would mean running every transaction one after another, which is correct and far too slow, so databases offer levels that trade a little correctness for a lot of throughput. The three problems the levels are named after are worth knowing: a dirty read is seeing another transaction's uncommitted changes; a non-repeatable read is running the same SELECT twice inside one transaction and getting different values because somebody committed in between; a phantom read is the same query returning extra rows the second time.

The four levels, from loosest to strictest, are READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ and SERIALIZABLE. The default is not the same everywhere, and this genuinely changes behaviour: MySQL's InnoDB defaults to REPEATABLE READ, while PostgreSQL, SQL Server and Oracle default to READ COMMITTED. The practical effect is that a long-running MySQL transaction keeps seeing the data as it was when it started, and the same code on PostgreSQL sees each committed change. Know which one you are on before writing anything that reads the same rows twice.

The failure that hits ordinary application code most often is the lost update, and no isolation level below SERIALIZABLE prevents it. Read a stock count of 10 into your program, subtract 1, write 9 back. Two requests doing that at the same time both read 10 and both write 9, and one sale has vanished. There are three standard fixes: do the arithmetic in SQL so the database performs the read and write as one operation; lock the row while you work with SELECT ... FOR UPDATE; or keep a version column and make the update conditional on the version you read, retrying if it has moved.

Locking brings deadlocks: two transactions each holding a row the other needs, waiting forever. Databases detect this and kill one of them with an error, so your application must be prepared to catch that error and retry the whole transaction rather than treat it as a crash. Deadlocks become rare when transactions are short and when every transaction touches rows in the same order — most deadlocks in real systems come from two code paths that update the same two tables in opposite sequence.

Example
-- The lost update, written the dangerous way:
--   SELECT stock FROM products WHERE id = 7;   -- application reads 10
--   ... application computes 10 - 1 ...
--   UPDATE products SET stock = 9 WHERE id = 7;
-- Two requests at once both read 10, both write 9. One sale lost.

-- Fix 1: let the database do the arithmetic, in one statement
UPDATE products SET stock = stock - 1 WHERE id = 7 AND stock > 0;
-- 0 rows affected means it was out of stock. No race window.

-- Fix 2: lock the row for the length of the transaction
START TRANSACTION;
SELECT stock FROM products WHERE id = 7 FOR UPDATE;
-- other transactions now wait here until this one finishes
UPDATE products SET stock = stock - 1 WHERE id = 7;
COMMIT;

-- Fix 3: optimistic locking with a version column
-- read: SELECT stock, version FROM products WHERE id = 7;   -- version 4
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 7 AND version = 4;
-- 0 rows affected means somebody else got there first: re-read and retry

-- Inspecting and setting the level
SELECT @@transaction_isolation;              -- MySQL 8.0
-- SHOW transaction_isolation;               -- PostgreSQL
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- MySQL: what actually caused the last deadlock
SHOW ENGINE INNODB STATUS;
Notes
  • A deadlock is not a bug in the database and it is not something you can design away entirely. It is a normal outcome under concurrency, and the correct response in application code is to catch the error and retry the transaction, usually two or three times with a short pause. Code that treats a deadlock as a fatal error will fail intermittently in production and work perfectly on your machine, because your machine has one user.

Running Destructive Statements Safely

UPDATE students SET branch = 'CSE'; is a complete, valid statement. It sets every student's branch to CSE, it does not ask whether you are sure, and there is no undo once it is committed. Forgetting a WHERE clause is the single most common way people destroy data, and it is entirely preventable with a habit that costs ten seconds.

The habit: write the SELECT first. Same WHERE clause, same table, and look at what comes back and how many rows there are. If a change meant for one student returns four hundred rows, you have just found out cheaply. Then convert it to the UPDATE or DELETE without touching the WHERE. On anything important, go further and run it inside an explicit transaction: execute the statement, check the reported row count, and only then COMMIT — or ROLLBACK if the number is wrong. The MySQL command-line client and MySQL Workbench also offer a safe-updates mode that refuses any UPDATE or DELETE whose WHERE clause does not use a key column, which is worth leaving switched on.

There are three ways to remove data and they are not interchangeable. DELETE removes rows one at a time, can be filtered with WHERE, fires triggers, respects foreign keys, and can be rolled back. TRUNCATE empties the whole table quickly by discarding and recreating it, takes no WHERE, and resets the AUTO_INCREMENT counter — and in MySQL it is a DDL statement, so it causes an implicit commit and cannot be rolled back, while PostgreSQL's TRUNCATE is transactional and can be. MySQL also refuses to truncate a table that other tables reference with a foreign key. DROP TABLE removes the table itself, along with its structure, indexes and permissions.

One structural protection is worth more than all the care in the world: the database account your application connects with should not be able to do most of this. Give it SELECT, INSERT, UPDATE and DELETE on the tables it uses, and nothing else — no DROP, no ALTER, no GRANT, and definitely not root. Then a bug or an injection that gets through cannot drop a table, because the account it is running as was never allowed to.

Example
-- Step 1: always. See what you are about to change.
SELECT id, name, branch FROM students WHERE roll_number = 'CS2026114';

-- Step 2: the same WHERE clause, now doing the work
UPDATE students SET branch = 'ECE' WHERE roll_number = 'CS2026114';

-- For anything that matters, make it reversible while you look
START TRANSACTION;
UPDATE students SET branch = 'ECE' WHERE roll_number = 'CS2026114';
-- the client reports: 1 row affected  -> as expected
COMMIT;
-- had it reported 400 rows affected:
-- ROLLBACK;

-- MySQL: refuse UPDATE/DELETE that lack a key in the WHERE clause
SET SQL_SAFE_UPDATES = 1;

-- The three ways to remove data
DELETE FROM orders WHERE ordered_at < '2020-01-01';  -- filtered, undoable
TRUNCATE TABLE session_log;   -- whole table, resets AUTO_INCREMENT
DROP TABLE session_log;       -- the table itself is gone

-- In MySQL, TRUNCATE cannot be rolled back:
-- START TRANSACTION;
-- TRUNCATE TABLE session_log;   -- implicit COMMIT happens here
-- ROLLBACK;                     -- too late, the table is empty
-- In PostgreSQL, the same sequence would restore the rows.

-- An application account that cannot destroy anything (MySQL)
CREATE USER 'campus_app'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON campus.* TO 'campus_app'@'localhost';
-- deliberately not granted: DROP, ALTER, CREATE, GRANT
FLUSH PRIVILEGES;
Notes
  • Before any change you cannot undo, take a copy of just the affected table: CREATE TABLE students_backup_20260802 AS SELECT * FROM students;. It takes one second, it costs a little disk space, and it converts a catastrophe into an inconvenience. Delete the copy a week later when you are sure. Note that this copies the rows only — indexes, keys and constraints are not carried over — so it is a data safety net, not a substitute for a real backup.

Backups You Have Actually Tested

Everything above reduces the chance of losing data. Backups are what you have left when it happens anyway — a bad migration, a failed disk, a mistyped DELETE, or a hosting account that disappears. For a student project this feels like ceremony; the first time you lose a week's work it stops feeling that way.

The everyday tool is a logical backup: mysqldump for MySQL, pg_dump for PostgreSQL. Both produce a plain text file of SQL statements that recreates the schema and the data, which makes them easy to inspect, easy to move between machines and easy to keep in a compressed archive. On MySQL with InnoDB, add --single-transaction so the dump is a consistent snapshot rather than a set of tables read at slightly different moments, which matters as soon as anybody is using the database while it runs.

Three rules that turn a backup file into an actual backup. First, a backup you have never restored is a hypothesis, not a backup — restore it into a scratch database occasionally and check that the row counts look right, because half-written dumps and wrong character sets are discovered at exactly the wrong moment otherwise. Second, a backup stored only on the machine it came from protects you against nothing that takes the machine with it; get a copy somewhere else. Third, automate it, because a backup that depends on somebody remembering is a backup that stops the week everyone is busy.

Two extras worth knowing exist above this baseline. Point-in-time recovery uses the transaction log — the binary log in MySQL, the write-ahead log in PostgreSQL — to replay changes from the moment of the last full backup up to a chosen second, which is how you recover from a mistake made at 3.15 p.m. without losing the rest of the day. And a dump contains every row of personal data in your database in plain text, so where you store it and who can read it is a real decision, not an afterthought.

Example
-- MySQL: back up one database, consistently, and compress it
-- mysqldump -u root -p --single-transaction campus > campus_20260802.sql
-- mysqldump -u root -p --single-transaction campus | gzip > campus.sql.gz

-- Schema only, or data only
-- mysqldump -u root -p --no-data campus > schema_only.sql
-- mysqldump -u root -p --no-create-info campus > data_only.sql

-- Restore into a database that already exists
-- mysql -u root -p campus < campus_20260802.sql

-- PostgreSQL
-- pg_dump -U postgres campus > campus_20260802.sql
-- psql   -U postgres -d campus -f campus_20260802.sql

-- Test the restore somewhere harmless, then check it looks right
CREATE DATABASE campus_restore_test;
-- mysql -u root -p campus_restore_test < campus_20260802.sql
SELECT COUNT(*) FROM campus_restore_test.students;
SELECT COUNT(*) FROM campus_restore_test.orders;
DROP DATABASE campus_restore_test;

-- Is point-in-time recovery even possible on this server? (MySQL)
SHOW VARIABLES LIKE 'log_bin';    -- ON means binary logging is enabled
Notes
  • Take a backup immediately before every schema migration, and keep it until the change has been running in production long enough to trust. Migrations are the single most common reason to need a restore, they are always run deliberately, and they are therefore the easiest moment in the whole lifecycle at which to be prepared.
Ask AI